BareGit

Complete the prototype milestone

- Add validated configuration, compiled games, series management, and safe
  Markdown rendering.
- Complete card mutations, asset recovery, WebGL form behavior, and HTTP
  routes.
- Expand C++, HTTP integration, and JavaScript test coverage.
Author: MetroWind <chris.corsair@gmail.com>
Date: Sun Aug 23 14:19:45 2026 -0700
Commit: c7e0d777211b4e62e6e44f90f7bd080764c03998

Changes

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 55e8d01..0623c28 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -19,12 +19,17 @@ add_executable(
     src/app.cpp
     src/asset_store.cpp
     src/card_service.cpp
+    src/config.cpp
     src/data.cpp
     src/data_sqlite.cpp
+    src/game_registry.cpp
     src/image_processor.cpp
+    src/markdown_renderer.cpp
     src/main.cpp
     src/multipart_reader.cpp
+    src/non_secret_random.cpp
     src/public_id.cpp
+    src/series_service.cpp
     src/startup.cpp
     src/url_builder.cpp
 )
@@ -93,15 +98,70 @@ if(CARD_COLLECTION_BUILD_TESTS)
     include(GoogleTest)
     gtest_discover_tests(data_mock_test)
 
+    add_executable(
+        config_test
+        src/config.cpp
+        src/non_secret_random.cpp
+        tests/config_test.cpp
+    )
+    target_compile_features(config_test PRIVATE cxx_std_23)
+    set_target_properties(config_test PROPERTIES CXX_EXTENSIONS OFF)
+    target_include_directories(
+        config_test
+        PRIVATE
+            ${libmw_SOURCE_DIR}/includes
+            src
+    )
+    target_link_libraries(
+        config_test
+        PRIVATE
+            GTest::gtest_main
+            mw::http-server
+            mw::mw
+            mw::url
+            tomlplusplus::tomlplusplus
+    )
+    gtest_discover_tests(config_test)
+
+    add_executable(
+        markdown_renderer_test
+        src/markdown_renderer.cpp
+        tests/markdown_renderer_test.cpp
+    )
+    target_compile_features(markdown_renderer_test PRIVATE cxx_std_23)
+    set_target_properties(
+        markdown_renderer_test
+        PROPERTIES CXX_EXTENSIONS OFF
+    )
+    target_include_directories(
+        markdown_renderer_test
+        PRIVATE
+            ${libmw_SOURCE_DIR}/includes
+            src
+    )
+    target_link_libraries(
+        markdown_renderer_test
+        PRIVATE
+            GTest::gtest_main
+            MacroDown::MacroDown
+            mw::mw
+            mw::url
+    )
+    gtest_discover_tests(markdown_renderer_test)
+
     add_executable(
         app_test
         src/app.cpp
         src/asset_store.cpp
         src/card_service.cpp
+        src/game_registry.cpp
+        src/non_secret_random.cpp
         src/data_fake.cpp
         src/image_processor.cpp
+        src/markdown_renderer.cpp
         src/multipart_reader.cpp
         src/public_id.cpp
+        src/series_service.cpp
         src/url_builder.cpp
         tests/app_test.cpp
     )
@@ -120,7 +180,9 @@ if(CARD_COLLECTION_BUILD_TESTS)
             ImageMagick::Magick++
             ImageMagick::MagickWand
             ImageMagick::MagickCore
+            MacroDown::MacroDown
             mw::http-server
+            mw::sqlite
             mw::url
             pantor::inja
             spdlog::spdlog
@@ -132,6 +194,57 @@ if(CARD_COLLECTION_BUILD_TESTS)
     )
     gtest_discover_tests(app_test)
 
+    add_executable(
+        app_integration_test
+        src/app.cpp
+        src/asset_store.cpp
+        src/card_service.cpp
+        src/data.cpp
+        src/data_sqlite.cpp
+        src/game_registry.cpp
+        src/image_processor.cpp
+        src/markdown_renderer.cpp
+        src/multipart_reader.cpp
+        src/non_secret_random.cpp
+        src/public_id.cpp
+        src/series_service.cpp
+        src/startup.cpp
+        src/url_builder.cpp
+        tests/app_integration_test.cpp
+    )
+    target_compile_features(app_integration_test PRIVATE cxx_std_23)
+    set_target_properties(
+        app_integration_test
+        PROPERTIES CXX_EXTENSIONS OFF
+    )
+    target_include_directories(
+        app_integration_test
+        PRIVATE
+            ${libmw_SOURCE_DIR}/includes
+            src
+    )
+    target_compile_definitions(
+        app_integration_test
+        PRIVATE
+            CARD_COLLECTION_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
+    )
+    target_link_libraries(
+        app_integration_test
+        PRIVATE
+            GTest::gtest_main
+            ImageMagick::Magick++
+            ImageMagick::MagickWand
+            ImageMagick::MagickCore
+            MacroDown::MacroDown
+            mw::http-server
+            mw::mw
+            mw::sqlite
+            mw::url
+            pantor::inja
+            spdlog::spdlog
+    )
+    gtest_discover_tests(app_integration_test)
+
     add_executable(
         data_fake_test
         src/data_fake.cpp
@@ -157,6 +270,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
         data_sqlite_test
         src/data.cpp
         src/data_sqlite.cpp
+        src/game_registry.cpp
         src/startup.cpp
         tests/data_sqlite_test.cpp
     )
@@ -184,8 +298,11 @@ if(CARD_COLLECTION_BUILD_TESTS)
         src/card_service.cpp
         src/data.cpp
         src/data_sqlite.cpp
+        src/game_registry.cpp
         src/image_processor.cpp
+        src/markdown_renderer.cpp
         src/multipart_reader.cpp
+        src/non_secret_random.cpp
         src/public_id.cpp
         src/startup.cpp
         tests/card_service_test.cpp
@@ -208,10 +325,25 @@ if(CARD_COLLECTION_BUILD_TESTS)
             ImageMagick::Magick++
             ImageMagick::MagickWand
             ImageMagick::MagickCore
+            MacroDown::MacroDown
             mw::http-server
             mw::mw
             mw::sqlite
             spdlog::spdlog
     )
     gtest_discover_tests(card_service_test)
+
+    find_program(NODE_EXECUTABLE node)
+    if(NODE_EXECUTABLE)
+        add_test(
+            NAME CardFormLogicTest
+            COMMAND ${NODE_EXECUTABLE} --test
+                    ${CMAKE_CURRENT_SOURCE_DIR}/tests/card_form_logic_test.js
+        )
+        add_test(
+            NAME CardPreviewMathTest
+            COMMAND ${NODE_EXECUTABLE} --test
+                    ${CMAKE_CURRENT_SOURCE_DIR}/tests/card_preview_math_test.js
+        )
+    endif()
 endif()
diff --git a/config.example.toml b/config.example.toml
new file mode 100644
index 0000000..6a46f20
--- /dev/null
+++ b/config.example.toml
@@ -0,0 +1,8 @@
+base_url = "http://127.0.0.1:8080/"
+listen_address = "127.0.0.1"
+listen_port = 8080
+static_root = "static"
+database_path = "var/card_collection.sqlite3"
+card_storage_root = "var/cards"
+avif_quality = 75
+thumbnail_long_side = 256
diff --git a/prd.md b/prd.md
index ba57ddf..ce5417b 100644
--- a/prd.md
+++ b/prd.md
@@ -120,8 +120,9 @@ existing game's schema require the normal versioned migration process.
 
 ## Image inputs and storage formats
 
-Artwork accepts JPEG, PNG, WebP, and AVIF. Foil-control textures accept PNG,
-WebP, and AVIF because they require an alpha channel.
+Artwork and foil-control textures accept JPEG, PNG, WebP, and AVIF. Neither
+image requires an alpha channel; WebGL treats a missing alpha channel as fully
+opaque.
 
 PNG inputs are converted to AVIF using a quality setting from the
 configuration file. JPEG, WebP, and AVIF inputs retain their source formats.
diff --git a/src/app.cpp b/src/app.cpp
index 27cb90b..2382566 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -16,9 +16,11 @@
 #include <vector>
 
 #include <spdlog/spdlog.h>
+#include <mw/utils.hpp>
 
 #include "public_id.h"
 #include "multipart_reader.h"
+#include "game_definition.h"
 
 namespace
 {
@@ -41,6 +43,12 @@ struct IndexCard
     std::string public_id;
 };
 
+struct HtmlSubstitution
+{
+    std::string marker;
+    RenderedHtml html;
+};
+
 RouteSegment literal(std::string value)
 {
     return {RouteSegmentKind::LITERAL, std::move(value)};
@@ -194,6 +202,40 @@ void respondBadRequest(
     response.set_content(message + "\n", "text/plain; charset=utf-8");
 }
 
+mw::E<std::string> renderWithHtml(
+    inja::Environment& environment,
+    const inja::Template& page_template,
+    const inja::json& data,
+    const std::vector<HtmlSubstitution>& substitutions)
+{
+    std::string output;
+    try
+    {
+        output = environment.render(page_template, data);
+    }
+    catch(const std::exception& error)
+    {
+        return std::unexpected(mw::runtimeError(error.what()));
+    }
+    for(const HtmlSubstitution& substitution : substitutions)
+    {
+        const std::size_t position = output.find(substitution.marker);
+        if(position == std::string::npos ||
+           output.find(
+               substitution.marker,
+               position + substitution.marker.size()) != std::string::npos)
+        {
+            return std::unexpected(mw::runtimeError(
+                "A trusted HTML marker was not rendered exactly once"));
+        }
+        output.replace(
+            position,
+            substitution.marker.size(),
+            substitution.html.value());
+    }
+    return output;
+}
+
 void respondOperationError(
     App::Response& response,
     const mw::Error& error,
@@ -211,22 +253,6 @@ void respondOperationError(
     respondInternalError(response);
 }
 
-std::string trimAscii(std::string value)
-{
-    const auto is_space = [](unsigned char character)
-    {
-        return std::isspace(character) != 0;
-    };
-    const auto begin = std::ranges::find_if_not(value, is_space);
-    const auto end = std::find_if_not(value.rbegin(), value.rend(), is_space)
-                         .base();
-    if(begin >= end)
-    {
-        return {};
-    }
-    return std::string(begin, end);
-}
-
 std::optional<std::string> optionalText(
     const std::unordered_map<std::string, std::string>& fields,
     const std::string& name)
@@ -236,7 +262,7 @@ std::optional<std::string> optionalText(
     {
         return std::nullopt;
     }
-    std::string value = trimAscii(position->second);
+    std::string value(mw::strip(position->second));
     if(value.empty())
     {
         return std::nullopt;
@@ -286,6 +312,68 @@ mw::E<std::int64_t> parseRevision(
     return revision;
 }
 
+std::optional<std::int64_t> parsePositiveId(const std::string& value)
+{
+    std::int64_t id = 0;
+    const auto result = std::from_chars(
+        value.data(), value.data() + value.size(), id);
+    if(value.empty() || result.ec != std::errc{} ||
+       result.ptr != value.data() + value.size() || id < 1)
+    {
+        return std::nullopt;
+    }
+    return id;
+}
+
+FormFields gameFields(
+    const std::unordered_map<std::string, std::string>& fields)
+{
+    FormFields result;
+    for(const auto& [name, value] : fields)
+    {
+        if(name.starts_with("game.") && name.size() > 5)
+        {
+            result.emplace(name.substr(5), value);
+        }
+    }
+    return result;
+}
+
+mw::E<std::vector<std::int64_t>> seriesMemberships(
+    const std::vector<std::string>& values,
+    const std::string& game_short_name,
+    DataSourceInterface& data_source)
+{
+    std::vector<std::int64_t> result;
+    result.reserve(values.size());
+    for(const std::string& value : values)
+    {
+        auto id = parsePositiveId(value);
+        if(!id)
+        {
+            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(
+                422, "A series was selected more than once"));
+        }
+        result.push_back(*id);
+    }
+    return result;
+}
+
 bool isRegularFile(const std::filesystem::path& path, std::int64_t card_id)
 {
     std::error_code filesystem_error;
@@ -306,22 +394,30 @@ 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<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_)
+    if(!data_source_ || !games_ || !random_)
     {
-        throw std::invalid_argument("App requires a data source");
+        throw std::invalid_argument(
+            "App requires data, games, and a random generator");
     }
 
     card_service_ = std::make_unique<CardService>(
         *data_source_,
+        *random_,
         ImageProcessor(config_.avif_quality, config_.thumbnail_long_side),
         AssetStore(config_.card_storage_root));
+    series_service_ = std::make_unique<SeriesService>(
+        *data_source_, *games_);
 
     templates_.set_html_autoescape(true);
     templates_.add_callback(
@@ -354,14 +450,59 @@ App::App(
                 arguments);
         });
     card_form_template_ = templates_.parse_template("card_form.html");
+    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");
+    series_delete_template_ = templates_.parse_template(
+        "series_delete.html");
+    series_form_template_ = templates_.parse_template("series_form.html");
+    series_index_template_ = templates_.parse_template("series_index.html");
 }
 
 void App::handleCardNew(
     [[maybe_unused]] const Request& request,
     Response& response)
 {
+    inja::json games = inja::json::array();
+    for(const GameDefinition* game : games_->games())
+    {
+        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())},
+        });
+    }
+    inja::json series = inja::json::array();
+    auto series_result = data_source_->getSeries();
+    if(!series_result)
+    {
+        spdlog::error(
+            "Failed to load series for card form: {}",
+            series_result.error().msg());
+        respondInternalError(response);
+        return;
+    }
+    for(const Series& item : *series_result)
+    {
+        series.push_back({
+            {"game", item.game_short_name},
+            {"id", item.id},
+            {"name", item.name},
+            {"selected", false},
+        });
+    }
     const inja::json template_data = {
         {"action_url", urlFor("cards")},
         {"back_url", urlFor("card-index")},
@@ -369,6 +510,7 @@ void App::handleCardNew(
         {"foil_action", "keep"},
         {"foil_url", ""},
         {"front_url", urlFor("static", {"card_placeholder.svg"})},
+        {"games", std::move(games)},
         {"has_foil", false},
         {"heading", "Create card"},
         {"long_description", ""},
@@ -385,6 +527,8 @@ void App::handleCardNew(
          urlFor("static", {"foil/spectral_xyz.bin"})},
         {"rarity", 0},
         {"revision", 0},
+        {"selected_game", ""},
+        {"series", std::move(series)},
         {"short_description", ""},
         {"submit_label", "Create card"},
         {"thumbnail_long_side", config_.thumbnail_long_side},
@@ -439,13 +583,6 @@ void App::handleCardEdit(
         return;
     }
     const Card& card = **card_result;
-    if(card.identity.game_short_name)
-    {
-        respondBadRequest(
-            response, "Game-specific cards are not implemented yet");
-        return;
-    }
-
     auto public_id_result = formatPublicId(card.identity);
     if(!public_id_result)
     {
@@ -468,6 +605,75 @@ void App::handleCardEdit(
             {{"v", std::to_string(card.revision)}});
     }
 
+    FormFields current_game_values;
+    const GameDefinition* selected_game = nullptr;
+    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);
+        if(!values)
+        {
+            spdlog::error(
+                "Failed to load game fields for card {}: {}",
+                card.id,
+                values.error().msg());
+            respondInternalError(response);
+            return;
+        }
+        current_game_values = std::move(*values);
+    }
+
+    inja::json games = inja::json::array();
+    for(const GameDefinition* game : games_->games())
+    {
+        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())},
+        });
+    }
+
+    auto all_series = data_source_->getSeries();
+    auto memberships = data_source_->getCardSeries(card.id);
+    if(!all_series || !memberships)
+    {
+        spdlog::error("Failed to load series for card {} edit form", card.id);
+        respondInternalError(response);
+        return;
+    }
+    inja::json series = inja::json::array();
+    for(const Series& item : *all_series)
+    {
+        series.push_back({
+            {"game", item.game_short_name},
+            {"id", item.id},
+            {"name", item.name},
+            {"selected", std::ranges::find(*memberships, item.id) !=
+                memberships->end()},
+        });
+    }
+
     const inja::json template_data = {
         {"action_url", urlFor("card", {public_id})},
         {"back_url", urlFor("card", {public_id})},
@@ -475,6 +681,7 @@ void App::handleCardEdit(
         {"foil_action", "keep"},
         {"foil_url", foil_url},
         {"front_url", front_url},
+        {"games", std::move(games)},
         {"has_foil", card.foil_extension.has_value()},
         {"heading", "Edit card"},
         {"long_description", card.long_description.value_or("")},
@@ -485,6 +692,8 @@ void App::handleCardEdit(
          urlFor("static", {"foil/card_preview.js"})},
         {"rarity", card.rarity},
         {"revision", card.revision},
+        {"selected_game", card.identity.game_short_name.value_or("")},
+        {"series", std::move(series)},
         {"shader_fragment_url",
          urlFor("static", {"foil/frag-shader.glsl"})},
         {"shader_vertex_url",
@@ -535,12 +744,9 @@ void App::handleCardCreate(
     }
 
     const auto game = upload->fields.find("game");
-    if(game != upload->fields.end() && !trimAscii(game->second).empty())
-    {
-        respondBadRequest(
-            response, "Game-specific cards are not implemented yet");
-        return;
-    }
+    const std::string game_short_name = game == upload->fields.end()
+        ? std::string()
+        : std::string(mw::strip(game->second));
     const auto source_mode = upload->fields.find("source_mode");
     if(source_mode != upload->fields.end() &&
        source_mode->second != "files" &&
@@ -553,7 +759,7 @@ void App::handleCardCreate(
     const auto name_position = upload->fields.find("name");
     const std::string name = name_position == upload->fields.end()
         ? std::string()
-        : trimAscii(name_position->second);
+        : std::string(mw::strip(name_position->second));
     if(name.empty())
     {
         respondBadRequest(response, "Card name is required");
@@ -569,12 +775,6 @@ void App::handleCardCreate(
         respondBadRequest(response, "Front artwork is required");
         return;
     }
-    if(!upload->series_ids.empty())
-    {
-        respondBadRequest(
-            response, "Loose cards cannot belong to a series");
-        return;
-    }
     auto rarity = parseRarity(upload->fields);
     if(!rarity)
     {
@@ -582,7 +782,7 @@ void App::handleCardCreate(
         return;
     }
 
-    auto created = card_service_->createLooseCard({
+    CreateCardInput input = {
         name,
         optionalText(upload->fields, "short_description"),
         optionalText(upload->fields, "long_description"),
@@ -591,7 +791,53 @@ void App::handleCardCreate(
         *upload->front,
         upload->foil,
         upload->thumbnail,
-    });
+    };
+    mw::E<std::string> created = std::unexpected(
+        mw::runtimeError("Card creation was not dispatched"));
+    if(game_short_name.empty())
+    {
+        if(!upload->series_ids.empty())
+        {
+            respondBadRequest(
+                response, "Loose cards cannot belong to a series");
+            return;
+        }
+        created = card_service_->createLooseCard(std::move(input));
+    }
+    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)
+        {
+            respondOperationError(
+                response,
+                metadata.error(),
+                "Failed to validate game metadata");
+            return;
+        }
+        auto memberships = seriesMemberships(
+            upload->series_ids, game_short_name, *data_source_);
+        if(!memberships)
+        {
+            respondOperationError(
+                response,
+                memberships.error(),
+                "Failed to validate series memberships");
+            return;
+        }
+        created = card_service_->createGameCard(
+            std::move(input),
+            *definition,
+            **metadata,
+            *memberships);
+    }
     if(!created)
     {
         respondOperationError(
@@ -635,12 +881,6 @@ void App::handleCardUpdate(
         respondNotFound(response);
         return;
     }
-    if((**card_result).identity.game_short_name)
-    {
-        respondBadRequest(
-            response, "Game-specific cards are not implemented yet");
-        return;
-    }
     if(!request.is_multipart_form_data())
     {
         respondBadRequest(response, "Expected a multipart form upload");
@@ -668,17 +908,10 @@ void App::handleCardUpdate(
         respondBadRequest(response, "Unknown image source mode");
         return;
     }
-    if(!upload->series_ids.empty())
-    {
-        respondBadRequest(
-            response, "Loose cards cannot belong to a series");
-        return;
-    }
-
     const auto name_position = upload->fields.find("name");
     const std::string name = name_position == upload->fields.end()
         ? std::string()
-        : trimAscii(name_position->second);
+        : std::string(mw::strip(name_position->second));
     if(name.empty())
     {
         respondBadRequest(response, "Card name is required");
@@ -748,7 +981,7 @@ void App::handleCardUpdate(
         return;
     }
 
-    auto updated = card_service_->updateLooseCard({
+    UpdateLooseCardInput input = {
         std::move(**card_result),
         *revision,
         name,
@@ -761,7 +994,55 @@ void App::handleCardUpdate(
         upload->front,
         upload->foil,
         upload->thumbnail,
-    });
+    };
+    mw::E<std::string> updated = std::unexpected(
+        mw::runtimeError("Card update was not dispatched"));
+    if(!input.current_card.identity.game_short_name)
+    {
+        if(!upload->series_ids.empty())
+        {
+            respondBadRequest(
+                response, "Loose cards cannot belong to a series");
+            return;
+        }
+        updated = card_service_->updateLooseCard(std::move(input));
+    }
+    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)
+        {
+            respondOperationError(
+                response,
+                metadata.error(),
+                "Failed to validate game metadata");
+            return;
+        }
+        auto membership_ids = seriesMemberships(
+            upload->series_ids, game_short_name, *data_source_);
+        if(!membership_ids)
+        {
+            respondOperationError(
+                response,
+                membership_ids.error(),
+                "Failed to validate series memberships");
+            return;
+        }
+        updated = card_service_->updateGameCard(
+            std::move(input),
+            *definition,
+            **metadata,
+            *membership_ids);
+    }
     if(!updated)
     {
         respondOperationError(
@@ -845,6 +1126,40 @@ void App::handleCardView(
         }
     }
 
+    inja::json game_fields = inja::json::array();
+    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)
+        {
+            spdlog::error(
+                "Card {} uses an unregistered compiled game",
+                card.id);
+            respondInternalError(response);
+            return;
+        }
+        game_name = std::string(game->displayName());
+        auto fields = data_source_->getGameDisplayFields(*game, card.id);
+        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_fields.push_back({
+                {"label", field.label},
+                {"value", field.value},
+            });
+        }
+    }
+
     auto membership_result = data_source_->getCardSeries(card.id);
     if(!membership_result)
     {
@@ -885,20 +1200,58 @@ void App::handleCardView(
         missing_assets.emplace_back("foil control");
     }
 
+    std::vector<HtmlSubstitution> html_substitutions;
+    std::string short_description_marker;
+    if(card.short_description)
+    {
+        auto rendered = MarkdownRenderer().render(*card.short_description);
+        if(!rendered)
+        {
+            spdlog::error(
+                "Failed to render short description for card {}: {}",
+                card.id,
+                rendered.error().msg());
+            respondInternalError(response);
+            return;
+        }
+        short_description_marker =
+            "CARD_COLLECTION_HTML_" + random_->hex(16);
+        html_substitutions.push_back({
+            short_description_marker, std::move(*rendered)});
+    }
+    std::string long_description_marker;
+    if(card.long_description)
+    {
+        auto rendered = MarkdownRenderer().render(*card.long_description);
+        if(!rendered)
+        {
+            spdlog::error(
+                "Failed to render long description for card {}: {}",
+                card.id,
+                rendered.error().msg());
+            respondInternalError(response);
+            return;
+        }
+        long_description_marker =
+            "CARD_COLLECTION_HTML_" + random_->hex(16);
+        html_substitutions.push_back({
+            long_description_marker, std::move(*rendered)});
+    }
+
     const inja::json template_data = {
         {"asset_warning", !missing_assets.empty()},
         {"back_url", urlFor("card-index")},
         {"display_id", uppercaseAscii(public_id)},
+        {"delete_url", urlFor("card-delete", {public_id})},
         {"edit_url", urlFor("card-edit", {public_id})},
         {"foil_url", foil_url.value_or("")},
         {"front_url", front_url},
-        {"game", card.identity.game_short_name
-             ? uppercaseAscii(*card.identity.game_short_name)
-             : "Loose card"},
+        {"game", game_name},
+        {"game_fields", std::move(game_fields)},
         {"has_foil", card.foil_extension.has_value()},
         {"has_long_description", card.long_description.has_value()},
         {"has_short_description", card.short_description.has_value()},
-        {"long_description", card.long_description.value_or("")},
+        {"long_description", long_description_marker},
         {"missing_assets", missing_assets},
         {"model_url", urlFor("static", {"foil/model/card.obj"})},
         {"name", card.name},
@@ -910,26 +1263,128 @@ void App::handleCardView(
          urlFor("static", {"foil/frag-shader.glsl"})},
         {"shader_vertex_url",
          urlFor("static", {"foil/vert-shader.glsl"})},
-        {"short_description", card.short_description.value_or("")},
+        {"short_description", short_description_marker},
         {"spectral_lut_url",
          urlFor("static", {"foil/spectral_xyz.bin"})},
         {"title", card.name + " · Card Collection"},
     };
 
+    try
+    {
+        response.status = 200;
+        auto content = renderWithHtml(
+            templates_,
+            card_view_template_,
+            template_data,
+            html_substitutions);
+        if(!content)
+        {
+            throw std::runtime_error(content.error().msg());
+        }
+        response.set_content(*content, "text/html; charset=utf-8");
+    }
+    catch(const std::exception& error)
+    {
+        spdlog::error("Failed to render card {}: {}", card.id, error.what());
+        respondInternalError(response);
+    }
+}
+
+void App::handleCardDeleteConfirm(
+    const Request& request,
+    Response& response)
+{
+    const auto parameter = request.path_params.find("id");
+    if(parameter == request.path_params.end())
+    {
+        respondNotFound(response);
+        return;
+    }
+    auto identity = parsePublicId(parameter->second);
+    if(!identity)
+    {
+        respondNotFound(response);
+        return;
+    }
+    auto card = data_source_->getCard(*identity);
+    if(!card || !*card)
+    {
+        if(!card)
+        {
+            spdlog::error(
+                "Failed to load card for deletion: {}",
+                card.error().msg());
+            respondInternalError(response);
+        }
+        else
+        {
+            respondNotFound(response);
+        }
+        return;
+    }
+    const inja::json template_data = {
+        {"action_url", urlFor("card-delete", {parameter->second})},
+        {"back_url", urlFor("card", {parameter->second})},
+        {"name", (**card).name},
+        {"title", "Delete card · Card Collection"},
+    };
     try
     {
         response.status = 200;
         response.set_content(
-            templates_.render(card_view_template_, template_data),
+            templates_.render(card_delete_template_, template_data),
             "text/html; charset=utf-8");
     }
     catch(const std::exception& error)
     {
-        spdlog::error("Failed to render card {}: {}", card.id, error.what());
+        spdlog::error("Failed to render card deletion: {}", error.what());
         respondInternalError(response);
     }
 }
 
+void App::handleCardDelete(
+    const Request& request,
+    Response& response)
+{
+    const auto parameter = request.path_params.find("id");
+    if(parameter == request.path_params.end())
+    {
+        respondNotFound(response);
+        return;
+    }
+    auto identity = parsePublicId(parameter->second);
+    if(!identity)
+    {
+        respondNotFound(response);
+        return;
+    }
+    auto card = data_source_->getCard(*identity);
+    if(!card || !*card)
+    {
+        if(!card)
+        {
+            spdlog::error(
+                "Failed to load card for deletion: {}",
+                card.error().msg());
+            respondInternalError(response);
+        }
+        else
+        {
+            respondNotFound(response);
+        }
+        return;
+    }
+    auto deleted = card_service_->deleteCard(**card);
+    if(!deleted)
+    {
+        respondOperationError(
+            response, deleted.error(), "Failed to delete a card");
+        return;
+    }
+    response.status = 303;
+    response.set_header("Location", urlFor("card-index"));
+}
+
 std::string App::urlFor(
     const std::string& name,
     const std::vector<std::string>& arguments,
@@ -1054,6 +1509,302 @@ void App::handleCardIndex(
     }
 }
 
+void App::handleSeriesIndex(
+    [[maybe_unused]] const Request& request,
+    Response& response)
+{
+    auto series_result = data_source_->getSeries();
+    if(!series_result)
+    {
+        spdlog::error(
+            "Failed to load the series index: {}",
+            series_result.error().msg());
+        respondInternalError(response);
+        return;
+    }
+    inja::json series = inja::json::array();
+    std::vector<HtmlSubstitution> html_substitutions;
+    for(const Series& item : *series_result)
+    {
+        const GameDefinition* game = games_->find(item.game_short_name);
+        auto rendered = MarkdownRenderer().render(item.description);
+        if(!rendered)
+        {
+            spdlog::error(
+                "Failed to render description for series {}: {}",
+                item.id,
+                rendered.error().msg());
+            respondInternalError(response);
+            return;
+        }
+        const std::string marker =
+            "CARD_COLLECTION_HTML_" + random_->hex(16);
+        html_substitutions.push_back({marker, std::move(*rendered)});
+        series.push_back({
+            {"delete_url",
+             urlFor("series-delete", {std::to_string(item.id)})},
+            {"description", marker},
+            {"has_description", !item.description.empty()},
+            {"edit_url",
+             urlFor("series-edit", {std::to_string(item.id)})},
+            {"game", game == nullptr
+                 ? uppercaseAscii(item.game_short_name)
+                 : std::string(game->displayName())},
+            {"name", item.name},
+        });
+    }
+    const inja::json template_data = {
+        {"can_create", !games_->games().empty()},
+        {"create_url", urlFor("series-new")},
+        {"series", std::move(series)},
+        {"title", "Series · Card Collection"},
+    };
+    try
+    {
+        response.status = 200;
+        auto content = renderWithHtml(
+            templates_,
+            series_index_template_,
+            template_data,
+            html_substitutions);
+        if(!content)
+        {
+            throw std::runtime_error(content.error().msg());
+        }
+        response.set_content(*content, "text/html; charset=utf-8");
+    }
+    catch(const std::exception& error)
+    {
+        spdlog::error("Failed to render the series index: {}", error.what());
+        respondInternalError(response);
+    }
+}
+
+void App::handleSeriesNew(
+    [[maybe_unused]] const Request& request,
+    Response& response)
+{
+    inja::json games = inja::json::array();
+    for(const GameDefinition* game : games_->games())
+    {
+        games.push_back({
+            {"display_name", std::string(game->displayName())},
+            {"short_name", std::string(game->shortName())},
+        });
+    }
+    const inja::json template_data = {
+        {"action_url", urlFor("series")},
+        {"back_url", urlFor("series-index")},
+        {"description", ""},
+        {"game", ""},
+        {"games", std::move(games)},
+        {"heading", "Create series"},
+        {"mode", "create"},
+        {"name", ""},
+        {"submit_label", "Create series"},
+        {"title", "Create series · Card Collection"},
+    };
+    try
+    {
+        response.status = 200;
+        response.set_content(
+            templates_.render(series_form_template_, template_data),
+            "text/html; charset=utf-8");
+    }
+    catch(const std::exception& error)
+    {
+        spdlog::error("Failed to render the series form: {}", error.what());
+        respondInternalError(response);
+    }
+}
+
+void App::handleSeriesCreate(
+    const Request& request,
+    Response& response)
+{
+    if(!request.has_param("game") || !request.has_param("name"))
+    {
+        respondBadRequest(response, "Game and series name are required");
+        return;
+    }
+    auto created = series_service_->create(
+        request.get_param_value("game"),
+        request.get_param_value("name"),
+        request.has_param("description")
+            ? request.get_param_value("description")
+            : std::string());
+    if(!created)
+    {
+        respondOperationError(
+            response, created.error(), "Failed to create a series");
+        return;
+    }
+    response.status = 303;
+    response.set_header("Location", urlFor("series-index"));
+}
+
+void App::handleSeriesEdit(
+    const Request& request,
+    Response& response)
+{
+    const auto parameter = request.path_params.find("id");
+    const auto id = parameter == request.path_params.end()
+        ? std::nullopt
+        : parsePositiveId(parameter->second);
+    if(!id)
+    {
+        respondNotFound(response);
+        return;
+    }
+    auto series_result = data_source_->getSeries(*id);
+    if(!series_result)
+    {
+        spdlog::error(
+            "Failed to load series {}: {}",
+            *id,
+            series_result.error().msg());
+        respondInternalError(response);
+        return;
+    }
+    if(!*series_result)
+    {
+        respondNotFound(response);
+        return;
+    }
+    const Series& series = **series_result;
+    const GameDefinition* game = games_->find(series.game_short_name);
+    const inja::json template_data = {
+        {"action_url", urlFor("series-item", {std::to_string(*id)})},
+        {"back_url", urlFor("series-index")},
+        {"description", series.description},
+        {"game", game == nullptr
+             ? uppercaseAscii(series.game_short_name)
+             : std::string(game->displayName())},
+        {"games", inja::json::array()},
+        {"heading", "Edit series"},
+        {"mode", "edit"},
+        {"name", series.name},
+        {"submit_label", "Save changes"},
+        {"title", "Edit " + series.name + " · Card Collection"},
+    };
+    try
+    {
+        response.status = 200;
+        response.set_content(
+            templates_.render(series_form_template_, template_data),
+            "text/html; charset=utf-8");
+    }
+    catch(const std::exception& error)
+    {
+        spdlog::error("Failed to render series {}: {}", *id, error.what());
+        respondInternalError(response);
+    }
+}
+
+void App::handleSeriesUpdate(
+    const Request& request,
+    Response& response)
+{
+    const auto parameter = request.path_params.find("id");
+    const auto id = parameter == request.path_params.end()
+        ? std::nullopt
+        : parsePositiveId(parameter->second);
+    if(!id || !request.has_param("name"))
+    {
+        respondBadRequest(response, "Valid series ID and name are required");
+        return;
+    }
+    auto updated = series_service_->update(
+        *id,
+        request.get_param_value("name"),
+        request.has_param("description")
+            ? request.get_param_value("description")
+            : std::string());
+    if(!updated)
+    {
+        respondOperationError(
+            response, updated.error(), "Failed to update a series");
+        return;
+    }
+    response.status = 303;
+    response.set_header("Location", urlFor("series-index"));
+}
+
+void App::handleSeriesDeleteConfirm(
+    const Request& request,
+    Response& response)
+{
+    const auto parameter = request.path_params.find("id");
+    const auto id = parameter == request.path_params.end()
+        ? std::nullopt
+        : parsePositiveId(parameter->second);
+    if(!id)
+    {
+        respondNotFound(response);
+        return;
+    }
+    auto series_result = data_source_->getSeries(*id);
+    if(!series_result || !*series_result)
+    {
+        if(!series_result)
+        {
+            spdlog::error(
+                "Failed to load series {} for deletion: {}",
+                *id,
+                series_result.error().msg());
+            respondInternalError(response);
+        }
+        else
+        {
+            respondNotFound(response);
+        }
+        return;
+    }
+    const inja::json template_data = {
+        {"action_url", urlFor("series-delete", {std::to_string(*id)})},
+        {"back_url", urlFor("series-index")},
+        {"name", (**series_result).name},
+        {"title", "Delete series · Card Collection"},
+    };
+    try
+    {
+        response.status = 200;
+        response.set_content(
+            templates_.render(series_delete_template_, template_data),
+            "text/html; charset=utf-8");
+    }
+    catch(const std::exception& error)
+    {
+        spdlog::error(
+            "Failed to render series deletion: {}", error.what());
+        respondInternalError(response);
+    }
+}
+
+void App::handleSeriesDelete(
+    const Request& request,
+    Response& response)
+{
+    const auto parameter = request.path_params.find("id");
+    const auto id = parameter == request.path_params.end()
+        ? std::nullopt
+        : parsePositiveId(parameter->second);
+    if(!id)
+    {
+        respondBadRequest(response, "A valid series ID is required");
+        return;
+    }
+    auto removed = series_service_->remove(*id);
+    if(!removed)
+    {
+        respondOperationError(
+            response, removed.error(), "Failed to delete a series");
+        return;
+    }
+    response.status = 303;
+    response.set_header("Location", urlFor("series-index"));
+}
+
 void App::setup()
 {
     const std::filesystem::path published_cards =
@@ -1087,6 +1838,33 @@ void App::setup()
     server.Post(
         getPath("card", {"id"}),
         std::bind_front(&App::handleCardUpdate, this));
+    server.Get(
+        getPath("card-delete", {"id"}),
+        std::bind_front(&App::handleCardDeleteConfirm, this));
+    server.Post(
+        getPath("card-delete", {"id"}),
+        std::bind_front(&App::handleCardDelete, this));
+    server.Get(
+        getPath("series-index"),
+        std::bind_front(&App::handleSeriesIndex, this));
+    server.Get(
+        getPath("series-new"),
+        std::bind_front(&App::handleSeriesNew, this));
+    server.Post(
+        getPath("series"),
+        std::bind_front(&App::handleSeriesCreate, this));
+    server.Get(
+        getPath("series-edit", {"id"}),
+        std::bind_front(&App::handleSeriesEdit, this));
+    server.Post(
+        getPath("series-item", {"id"}),
+        std::bind_front(&App::handleSeriesUpdate, this));
+    server.Get(
+        getPath("series-delete", {"id"}),
+        std::bind_front(&App::handleSeriesDeleteConfirm, this));
+    server.Post(
+        getPath("series-delete", {"id"}),
+        std::bind_front(&App::handleSeriesDelete, this));
 }
 
 std::string App::getPath(
diff --git a/src/app.h b/src/app.h
index 9bed86e..2104776 100644
--- a/src/app.h
+++ b/src/app.h
@@ -10,6 +10,9 @@
 #include "card_service.h"
 #include "config.h"
 #include "data.h"
+#include "game_registry.h"
+#include "non_secret_random.h"
+#include "series_service.h"
 #include "url_builder.h"
 
 /// Card Collection HTTP application and named-route owner.
@@ -31,7 +34,9 @@ public:
     /// Construct the application from configuration and a data source.
     App(
         const Config& config,
-        std::unique_ptr<DataSourceInterface> data_source);
+        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.
     std::string urlFor(
@@ -63,6 +68,37 @@ public:
         Response& response,
         const ContentReader& content_reader);
 
+    /// Render the card-delete confirmation page.
+    void handleCardDeleteConfirm(
+        const Request& request,
+        Response& response);
+
+    /// Delete one card and redirect to the index.
+    void handleCardDelete(const Request& request, Response& response);
+
+    /// Render the series administration index.
+    void handleSeriesIndex(const Request& request, Response& response);
+
+    /// Render the create-series form.
+    void handleSeriesNew(const Request& request, Response& response);
+
+    /// Persist a new series.
+    void handleSeriesCreate(const Request& request, Response& response);
+
+    /// Render the edit-series form.
+    void handleSeriesEdit(const Request& request, Response& response);
+
+    /// Persist changes to one series.
+    void handleSeriesUpdate(const Request& request, Response& response);
+
+    /// Render the delete-series confirmation page.
+    void handleSeriesDeleteConfirm(
+        const Request& request,
+        Response& response);
+
+    /// Delete one series and redirect to the series index.
+    void handleSeriesDelete(const Request& request, Response& response);
+
 private:
     /// Register implemented handlers and static mounts.
     void setup() override;
@@ -77,10 +113,17 @@ private:
 
     Config config_;
     std::unique_ptr<DataSourceInterface> data_source_;
+    std::unique_ptr<GameRegistry> games_;
+    std::unique_ptr<NonSecretRandom> random_;
     std::unique_ptr<CardService> card_service_;
+    std::unique_ptr<SeriesService> series_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 series_delete_template_;
+    inja::Template series_form_template_;
+    inja::Template series_index_template_;
 };
diff --git a/src/asset_store.cpp b/src/asset_store.cpp
index a059aae..bec17f1 100644
--- a/src/asset_store.cpp
+++ b/src/asset_store.cpp
@@ -1,12 +1,79 @@
 #include "asset_store.h"
 
+#include <charconv>
 #include <filesystem>
+#include <optional>
 #include <string>
 #include <system_error>
 #include <utility>
 
 #include <spdlog/spdlog.h>
 
+#include "data.h"
+#include "public_id.h"
+
+namespace
+{
+
+struct RecoveryName
+{
+    std::string public_id;
+    std::int64_t revision;
+};
+
+std::optional<RecoveryName> parseRecoveryName(
+    const std::string& name,
+    std::string_view prefix)
+{
+    if(!name.starts_with(prefix))
+    {
+        return std::nullopt;
+    }
+    const std::size_t begin = prefix.size();
+    std::size_t marker = name.find("-r", begin);
+    while(marker != std::string::npos)
+    {
+        const std::size_t revision_begin = marker + 2;
+        const std::size_t suffix = name.find('-', revision_begin);
+        if(suffix != std::string::npos)
+        {
+            const std::string public_id = name.substr(begin, marker - begin);
+            const std::string revision_text = name.substr(
+                revision_begin, suffix - revision_begin);
+            auto identity = parsePublicId(public_id);
+            std::int64_t revision = 0;
+            const auto parsed = std::from_chars(
+                revision_text.data(),
+                revision_text.data() + revision_text.size(),
+                revision);
+            if(identity && !revision_text.empty() &&
+               parsed.ec == std::errc{} &&
+               parsed.ptr == revision_text.data() + revision_text.size() &&
+               revision > 0)
+            {
+                return RecoveryName{public_id, revision};
+            }
+        }
+        marker = name.find("-r", marker + 2);
+    }
+    return std::nullopt;
+}
+
+mw::E<void> removeDirectory(const std::filesystem::path& path)
+{
+    std::error_code filesystem_error;
+    std::filesystem::remove_all(path, filesystem_error);
+    if(filesystem_error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to remove abandoned card assets: " +
+            filesystem_error.message()));
+    }
+    return {};
+}
+
+} // namespace
+
 AssetStore::AssetStore(std::filesystem::path card_storage_root)
         : published_root_(
               std::move(card_storage_root) / "published")
@@ -58,12 +125,14 @@ mw::E<void> AssetStore::publish(
 
 mw::E<AssetReplacement> AssetStore::replace(
     const std::filesystem::path& staging_directory,
-    const std::string& public_id) const
+    const std::string& public_id,
+    std::int64_t previous_revision) const
 {
     const std::filesystem::path destination = published_root_ / public_id;
-    const std::filesystem::path previous =
-        staging_directory.parent_path() /
-        (staging_directory.filename().string() + ".previous");
+    const std::filesystem::path previous = staging_directory.parent_path() /
+        ("backup-" + public_id + "-r" +
+         std::to_string(previous_revision) + "-" +
+         staging_directory.filename().string());
     std::error_code filesystem_error;
     if(!std::filesystem::is_directory(destination, filesystem_error))
     {
@@ -133,6 +202,81 @@ void AssetStore::finish(const AssetReplacement& replacement) const
     }
 }
 
+mw::E<TrashedAssets> AssetStore::trash(
+    const std::string& public_id,
+    std::int64_t revision,
+    const std::string& suffix) const
+{
+    const std::filesystem::path source = published_root_ / public_id;
+    std::error_code filesystem_error;
+    if(!std::filesystem::exists(source, filesystem_error))
+    {
+        if(filesystem_error)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Failed to inspect card assets for deletion: " +
+                filesystem_error.message()));
+        }
+        return TrashedAssets{public_id, {}};
+    }
+    const std::filesystem::path trash_root =
+        published_root_.parent_path() / ".trash";
+    std::filesystem::create_directories(trash_root, filesystem_error);
+    if(filesystem_error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to create the private trash directory: " +
+            filesystem_error.message()));
+    }
+    const std::filesystem::path destination = trash_root /
+        (public_id + "-r" + std::to_string(revision) + "-" + suffix);
+    std::filesystem::rename(source, destination, filesystem_error);
+    if(filesystem_error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to move card assets into private trash: " +
+            filesystem_error.message()));
+    }
+    return TrashedAssets{public_id, destination};
+}
+
+void AssetStore::restore(const TrashedAssets& trashed) const
+{
+    if(trashed.trash_directory.empty())
+    {
+        return;
+    }
+    std::error_code filesystem_error;
+    std::filesystem::rename(
+        trashed.trash_directory,
+        published_root_ / trashed.public_id,
+        filesystem_error);
+    if(filesystem_error)
+    {
+        spdlog::critical(
+            "Failed to restore deleted assets for card {}: {}",
+            trashed.public_id,
+            filesystem_error.message());
+    }
+}
+
+void AssetStore::finish(const TrashedAssets& trashed) const
+{
+    if(trashed.trash_directory.empty())
+    {
+        return;
+    }
+    std::error_code filesystem_error;
+    std::filesystem::remove_all(trashed.trash_directory, filesystem_error);
+    if(filesystem_error)
+    {
+        spdlog::warn(
+            "Failed to remove committed trash for card {}: {}",
+            trashed.public_id,
+            filesystem_error.message());
+    }
+}
+
 void AssetStore::removePublished(const std::string& public_id) const
 {
     std::error_code filesystem_error;
@@ -146,3 +290,160 @@ void AssetStore::removePublished(const std::string& public_id) const
             filesystem_error.message());
     }
 }
+
+mw::E<void> AssetStore::reconcile(DataSourceInterface& data_source) const
+{
+    const std::filesystem::path storage_root = published_root_.parent_path();
+    const std::filesystem::path staging_root = storage_root / ".staging";
+    const std::filesystem::path trash_root = storage_root / ".trash";
+    std::error_code filesystem_error;
+    std::filesystem::create_directories(staging_root, filesystem_error);
+    if(filesystem_error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to inspect staging during recovery: " +
+            filesystem_error.message()));
+    }
+
+    for(const std::filesystem::directory_entry& entry :
+        std::filesystem::directory_iterator(staging_root))
+    {
+        const std::string name = entry.path().filename().string();
+        auto recovery = parseRecoveryName(name, "backup-");
+        if(!recovery)
+        {
+            if(!name.starts_with("backup-"))
+            {
+                auto removed = removeDirectory(entry.path());
+                if(!removed)
+                {
+                    return removed;
+                }
+            }
+            else
+            {
+                spdlog::warn("Leaving malformed recovery entry {}", name);
+            }
+            continue;
+        }
+        auto identity = parsePublicId(recovery->public_id);
+        auto card = data_source.getCard(*identity);
+        if(!card)
+        {
+            return std::unexpected(std::move(card.error()));
+        }
+        const std::filesystem::path canonical =
+            published_root_ / recovery->public_id;
+        if(*card && (**card).revision == recovery->revision)
+        {
+            auto removed = removeDirectory(canonical);
+            if(!removed)
+            {
+                return removed;
+            }
+            std::filesystem::rename(
+                entry.path(), canonical, filesystem_error);
+            if(filesystem_error)
+            {
+                return std::unexpected(mw::runtimeError(
+                    "Failed to restore interrupted card edit: " +
+                    filesystem_error.message()));
+            }
+        }
+        else if(*card && (**card).revision > recovery->revision)
+        {
+            auto removed = removeDirectory(entry.path());
+            if(!removed)
+            {
+                return removed;
+            }
+        }
+        else if(!*card)
+        {
+            auto removed = removeDirectory(entry.path());
+            if(!removed)
+            {
+                return removed;
+            }
+        }
+        else
+        {
+            return std::unexpected(mw::runtimeError(
+                "A card revision predates its recovery backup"));
+        }
+    }
+
+    std::filesystem::create_directories(trash_root, filesystem_error);
+    if(filesystem_error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to inspect trash during recovery: " +
+            filesystem_error.message()));
+    }
+    for(const std::filesystem::directory_entry& entry :
+        std::filesystem::directory_iterator(trash_root))
+    {
+        auto recovery = parseRecoveryName(
+            entry.path().filename().string(), "");
+        if(!recovery)
+        {
+            spdlog::warn(
+                "Leaving malformed trash entry {}",
+                entry.path().filename().string());
+            continue;
+        }
+        auto identity = parsePublicId(recovery->public_id);
+        auto card = data_source.getCard(*identity);
+        if(!card)
+        {
+            return std::unexpected(std::move(card.error()));
+        }
+        const std::filesystem::path canonical =
+            published_root_ / recovery->public_id;
+        if(*card && !std::filesystem::exists(canonical))
+        {
+            std::filesystem::rename(
+                entry.path(), canonical, filesystem_error);
+            if(filesystem_error)
+            {
+                return std::unexpected(mw::runtimeError(
+                    "Failed to restore interrupted card deletion: " +
+                    filesystem_error.message()));
+            }
+        }
+        else if(!*card || std::filesystem::exists(canonical))
+        {
+            auto removed = removeDirectory(entry.path());
+            if(!removed)
+            {
+                return removed;
+            }
+        }
+    }
+
+    for(const std::filesystem::directory_entry& entry :
+        std::filesystem::directory_iterator(published_root_))
+    {
+        const std::string public_id = entry.path().filename().string();
+        auto identity = parsePublicId(public_id);
+        if(!identity)
+        {
+            spdlog::warn("Leaving non-card published entry {}", public_id);
+            continue;
+        }
+        auto card = data_source.getCard(*identity);
+        if(!card)
+        {
+            return std::unexpected(std::move(card.error()));
+        }
+        if(!*card)
+        {
+            auto removed = removeDirectory(entry.path());
+            if(!removed)
+            {
+                return removed;
+            }
+        }
+    }
+    return {};
+}
diff --git a/src/asset_store.h b/src/asset_store.h
index 7a97840..662c358 100644
--- a/src/asset_store.h
+++ b/src/asset_store.h
@@ -1,10 +1,13 @@
 #pragma once
 
+#include <cstdint>
 #include <filesystem>
 #include <string>
 
 #include <mw/error.hpp>
 
+class DataSourceInterface;
+
 /// Published asset directory retained until an edit transaction commits.
 struct AssetReplacement
 {
@@ -15,6 +18,16 @@ struct AssetReplacement
     std::filesystem::path previous_directory;
 };
 
+/// Published card assets retained until a delete transaction commits.
+struct TrashedAssets
+{
+    /// Public ID whose directory was moved.
+    std::string public_id;
+
+    /// Private trash path, empty when no published directory existed.
+    std::filesystem::path trash_directory;
+};
+
 /// Atomically publish and remove complete card asset directories.
 class AssetStore
 {
@@ -33,7 +46,8 @@ public:
     /// Swap a complete staging directory with existing published assets.
     mw::E<AssetReplacement> replace(
         const std::filesystem::path& staging_directory,
-        const std::string& public_id) const;
+        const std::string& public_id,
+        std::int64_t previous_revision) const;
 
     /// Restore the previous assets after a database rollback.
     void restore(const AssetReplacement& replacement) const;
@@ -41,9 +55,24 @@ public:
     /// Remove previous assets after the database update commits.
     void finish(const AssetReplacement& replacement) const;
 
+    /// Move published assets to a uniquely named private trash directory.
+    mw::E<TrashedAssets> trash(
+        const std::string& public_id,
+        std::int64_t revision,
+        const std::string& suffix) const;
+
+    /// Restore assets after a card-delete transaction rollback.
+    void restore(const TrashedAssets& trashed) const;
+
+    /// Permanently remove assets after a card-delete transaction commits.
+    void finish(const TrashedAssets& trashed) const;
+
     /// Remove one precisely identified published directory after rollback.
     void removePublished(const std::string& public_id) const;
 
+    /// Reconcile private transitions and orphaned public directories.
+    mw::E<void> reconcile(DataSourceInterface& data_source) const;
+
 private:
     std::filesystem::path published_root_;
 };
diff --git a/src/card_service.cpp b/src/card_service.cpp
index c7c4c87..408a364 100644
--- a/src/card_service.cpp
+++ b/src/card_service.cpp
@@ -2,27 +2,18 @@
 
 #include <cstdint>
 #include <filesystem>
-#include <limits>
 #include <optional>
-#include <random>
 #include <string>
 #include <system_error>
 #include <utility>
 
 #include "public_id.h"
+#include "game_definition.h"
 
 namespace
 {
 
-inline constexpr int MAX_ID_ATTEMPTS = 64;
-
-std::uint32_t randomLooseNumber()
-{
-    thread_local std::mt19937 generator(std::random_device{}());
-    thread_local std::uniform_int_distribution<std::uint32_t> distribution(
-        0, std::numeric_limits<std::uint32_t>::max());
-    return distribution(generator);
-}
+inline constexpr int MAX_ID_ATTEMPTS = 128;
 
 mw::E<void> copyAsset(
     const std::filesystem::path& source,
@@ -47,9 +38,11 @@ mw::E<void> copyAsset(
 
 CardService::CardService(
     DataSourceInterface& data_source,
+    NonSecretRandom& random,
     ImageProcessor image_processor,
     AssetStore asset_store)
         : data_source_(data_source),
+          random_(random),
           image_processor_(std::move(image_processor)),
           asset_store_(std::move(asset_store))
 {}
@@ -57,6 +50,41 @@ CardService::CardService(
 mw::E<std::string> CardService::createLooseCard(
     CreateLooseCardInput input)
 {
+    return createCard(std::move(input), nullptr, nullptr, {});
+}
+
+mw::E<std::string> CardService::createGameCard(
+    CreateCardInput input,
+    const GameDefinition& game,
+    const GameCardMetadata& metadata,
+    const std::vector<std::int64_t>& series_ids)
+{
+    return createCard(
+        std::move(input), &game, &metadata, series_ids);
+}
+
+mw::E<std::string> CardService::createCard(
+    CreateCardInput input,
+    const GameDefinition* game,
+    const GameCardMetadata* metadata,
+    const std::vector<std::int64_t>& series_ids)
+{
+    if(input.short_description)
+    {
+        auto rendered = markdown_renderer_.render(*input.short_description);
+        if(!rendered)
+        {
+            return std::unexpected(std::move(rendered.error()));
+        }
+    }
+    if(input.long_description)
+    {
+        auto rendered = markdown_renderer_.render(*input.long_description);
+        if(!rendered)
+        {
+            return std::unexpected(std::move(rendered.error()));
+        }
+    }
     auto front = image_processor_.process(
         input.front, CardAssetType::FRONT_ART);
     if(!front)
@@ -117,30 +145,48 @@ mw::E<std::string> CardService::createLooseCard(
         return std::unexpected(std::move(transaction.error()));
     }
 
-    std::optional<std::uint32_t> number;
-    for(int attempt = 0; attempt < MAX_ID_ATTEMPTS; ++attempt)
+    std::uint64_t number = 0;
+    if(game != nullptr)
     {
-        const std::uint32_t candidate = randomLooseNumber();
-        auto exists = (*transaction)->looseNumberExists(candidate);
-        if(!exists)
-        {
-            return std::unexpected(std::move(exists.error()));
-        }
-        if(!*exists)
+        auto allocated = (*transaction)->allocateGameNumber(
+            std::string(game->shortName()));
+        if(!allocated)
         {
-            number = candidate;
-            break;
+            return std::unexpected(std::move(allocated.error()));
         }
+        number = *allocated;
     }
-    if(!number)
+    else
     {
-        return std::unexpected(mw::runtimeError(
-            "Failed to allocate a unique loose-card number"));
+        std::optional<std::uint32_t> loose_number;
+        for(int attempt = 0; attempt < MAX_ID_ATTEMPTS; ++attempt)
+        {
+            const std::uint32_t candidate = random_.next();
+            auto exists = (*transaction)->looseNumberExists(candidate);
+            if(!exists)
+            {
+                return std::unexpected(std::move(exists.error()));
+            }
+            if(!*exists)
+            {
+                loose_number = candidate;
+                break;
+            }
+        }
+        if(!loose_number)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Failed to allocate a unique loose-card number"));
+        }
+        number = *loose_number;
     }
 
     Card card = {
         0,
-        {std::nullopt, *number},
+        {game == nullptr
+             ? std::nullopt
+             : std::optional<std::string>(game->shortName()),
+         number},
         std::move(input.name),
         std::move(input.short_description),
         std::move(input.long_description),
@@ -151,7 +197,7 @@ mw::E<std::string> CardService::createLooseCard(
         1,
     };
     auto inserted = (*transaction)->insertCard(
-        card, nullptr, nullptr, {});
+        card, game, metadata, series_ids);
     if(!inserted)
     {
         return std::unexpected(std::move(inserted.error()));
@@ -180,12 +226,51 @@ mw::E<std::string> CardService::createLooseCard(
 
 mw::E<std::string> CardService::updateLooseCard(
     UpdateLooseCardInput input)
+{
+    return updateCard(std::move(input), nullptr, nullptr, {});
+}
+
+mw::E<std::string> CardService::updateGameCard(
+    UpdateLooseCardInput input,
+    const GameDefinition& game,
+    const GameCardMetadata& metadata,
+    const std::vector<std::int64_t>& series_ids)
+{
+    return updateCard(
+        std::move(input), &game, &metadata, series_ids);
+}
+
+mw::E<std::string> CardService::updateCard(
+    UpdateLooseCardInput input,
+    const GameDefinition* game,
+    const GameCardMetadata* metadata,
+    const std::vector<std::int64_t>& series_ids)
 {
     Card& card = input.current_card;
-    if(card.id <= 0 || card.identity.game_short_name)
+    if(input.short_description)
+    {
+        auto rendered = markdown_renderer_.render(*input.short_description);
+        if(!rendered)
+        {
+            return std::unexpected(std::move(rendered.error()));
+        }
+    }
+    if(input.long_description)
+    {
+        auto rendered = markdown_renderer_.render(*input.long_description);
+        if(!rendered)
+        {
+            return std::unexpected(std::move(rendered.error()));
+        }
+    }
+    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))
     {
         return std::unexpected(mw::httpError(
-            400, "Only existing loose cards can be edited"));
+            400, "Card identity does not match its compiled game"));
     }
     if(input.expected_revision != card.revision)
     {
@@ -351,7 +436,8 @@ mw::E<std::string> CardService::updateLooseCard(
             409, "The card was changed in another request"));
     }
 
-    auto updated = (*transaction)->updateCard(card, nullptr, nullptr, {});
+    auto updated = (*transaction)->updateCard(
+        card, game, metadata, series_ids);
     if(!updated)
     {
         return std::unexpected(std::move(updated.error()));
@@ -359,7 +445,9 @@ mw::E<std::string> CardService::updateLooseCard(
     if(rendering_changed)
     {
         auto replaced = asset_store_.replace(
-            input.staging_directory, *public_id);
+            input.staging_directory,
+            *public_id,
+            input.expected_revision);
         if(!replaced)
         {
             return std::unexpected(std::move(replaced.error()));
@@ -382,3 +470,46 @@ mw::E<std::string> CardService::updateLooseCard(
     }
     return *public_id;
 }
+
+mw::E<void> CardService::deleteCard(const Card& card)
+{
+    auto public_id = formatPublicId(card.identity);
+    if(!public_id)
+    {
+        return std::unexpected(std::move(public_id.error()));
+    }
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto current = (*transaction)->getCardForUpdate(card.id);
+    if(!current)
+    {
+        return std::unexpected(std::move(current.error()));
+    }
+    if(!*current)
+    {
+        return std::unexpected(mw::httpError(404, "Card not found"));
+    }
+    auto trashed = asset_store_.trash(
+        *public_id, (**current).revision, random_.hex(16));
+    if(!trashed)
+    {
+        return std::unexpected(std::move(trashed.error()));
+    }
+    auto deleted = (*transaction)->deleteCard(card.id);
+    if(!deleted)
+    {
+        asset_store_.restore(*trashed);
+        return std::unexpected(std::move(deleted.error()));
+    }
+    auto committed = (*transaction)->commit();
+    if(!committed)
+    {
+        asset_store_.restore(*trashed);
+        return std::unexpected(std::move(committed.error()));
+    }
+    asset_store_.finish(*trashed);
+    return {};
+}
diff --git a/src/card_service.h b/src/card_service.h
index f91ae6b..7ea4ea1 100644
--- a/src/card_service.h
+++ b/src/card_service.h
@@ -11,9 +11,11 @@
 #include "asset_store.h"
 #include "data.h"
 #include "image_processor.h"
+#include "markdown_renderer.h"
+#include "non_secret_random.h"
 
-/// Validated common fields and staged files for a new loose card.
-struct CreateLooseCardInput
+/// Validated common fields and staged files for a new card.
+struct CreateCardInput
 {
     /// Human-readable card name.
     std::string name;
@@ -40,6 +42,9 @@ struct CreateLooseCardInput
     std::optional<std::filesystem::path> thumbnail;
 };
 
+/// Create-card input retained as the loose-card handler's explicit name.
+using CreateLooseCardInput = CreateCardInput;
+
 /// Requested handling for an existing required artwork asset.
 enum class FrontAssetAction
 {
@@ -102,17 +107,51 @@ public:
     /// Construct a card creation service from its owned boundaries.
     CardService(
         DataSourceInterface& data_source,
+        NonSecretRandom& random,
         ImageProcessor image_processor,
         AssetStore asset_store);
 
     /// Create a loose card and return its canonical public ID.
     mw::E<std::string> createLooseCard(CreateLooseCardInput input);
 
+    /// Create a compiled-game card with validated metadata and memberships.
+    mw::E<std::string> createGameCard(
+        CreateCardInput input,
+        const GameDefinition& game,
+        const GameCardMetadata& metadata,
+        const std::vector<std::int64_t>& series_ids);
+
     /// Update a loose card and return its unchanged canonical public ID.
     mw::E<std::string> updateLooseCard(UpdateLooseCardInput input);
 
+    /// Update a compiled-game card and its validated metadata/memberships.
+    mw::E<std::string> updateGameCard(
+        UpdateLooseCardInput input,
+        const GameDefinition& game,
+        const GameCardMetadata& metadata,
+        const std::vector<std::int64_t>& series_ids);
+
+    /// Delete a card and its published assets.
+    mw::E<void> deleteCard(const Card& card);
+
 private:
+    /// Create a loose or compiled-game card through the common pipeline.
+    mw::E<std::string> createCard(
+        CreateCardInput input,
+        const GameDefinition* game,
+        const GameCardMetadata* metadata,
+        const std::vector<std::int64_t>& series_ids);
+
+    /// Update a loose or compiled-game card through the common pipeline.
+    mw::E<std::string> updateCard(
+        UpdateLooseCardInput input,
+        const GameDefinition* game,
+        const GameCardMetadata* metadata,
+        const std::vector<std::int64_t>& series_ids);
+
     DataSourceInterface& data_source_;
+    NonSecretRandom& random_;
     ImageProcessor image_processor_;
     AssetStore asset_store_;
+    MarkdownRenderer markdown_renderer_;
 };
diff --git a/src/config.cpp b/src/config.cpp
new file mode 100644
index 0000000..c06b8ac
--- /dev/null
+++ b/src/config.cpp
@@ -0,0 +1,279 @@
+#include "config.h"
+
+#include <array>
+#include <cstdint>
+#include <filesystem>
+#include <optional>
+#include <set>
+#include <string>
+#include <string_view>
+#include <system_error>
+#include <utility>
+
+#include <toml++/toml.hpp>
+
+namespace
+{
+
+const std::set<std::string> CONFIG_KEYS = {
+    "avif_quality",
+    "base_url",
+    "card_storage_root",
+    "database_path",
+    "listen_address",
+    "listen_port",
+    "static_root",
+    "thumbnail_long_side",
+};
+
+mw::E<std::string> requiredString(
+    const toml::table& table,
+    std::string_view name)
+{
+    auto value = table[name].value<std::string>();
+    if(!value)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Configuration key '" + std::string(name) +
+            "' must be a string"));
+    }
+    return std::move(*value);
+}
+
+mw::E<std::int64_t> requiredInteger(
+    const toml::table& table,
+    std::string_view name)
+{
+    auto value = table[name].value<std::int64_t>();
+    if(!value)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Configuration key '" + std::string(name) +
+            "' must be an integer"));
+    }
+    return *value;
+}
+
+std::filesystem::path normalizedPath(
+    const std::filesystem::path& config_directory,
+    const std::string& value)
+{
+    std::filesystem::path path(value);
+    if(path.is_relative())
+    {
+        path = config_directory / path;
+    }
+    return std::filesystem::absolute(path).lexically_normal();
+}
+
+bool pathContains(
+    const std::filesystem::path& parent,
+    const std::filesystem::path& child)
+{
+    auto parent_position = parent.begin();
+    auto child_position = child.begin();
+    while(parent_position != parent.end() &&
+          child_position != child.end() &&
+          *parent_position == *child_position)
+    {
+        ++parent_position;
+        ++child_position;
+    }
+    return parent_position == parent.end();
+}
+
+mw::E<void> createDirectory(
+    const std::filesystem::path& path,
+    std::string_view description)
+{
+    std::error_code filesystem_error;
+    std::filesystem::create_directories(path, filesystem_error);
+    if(filesystem_error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to create " + std::string(description) + ": " +
+            filesystem_error.message()));
+    }
+    return {};
+}
+
+} // namespace
+
+mw::E<Config> loadConfig(const std::filesystem::path& config_path)
+{
+    toml::table table;
+    try
+    {
+        table = toml::parse_file(config_path.string());
+    }
+    catch(const toml::parse_error& error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to parse configuration: " +
+            std::string(error.description())));
+    }
+
+    for(const auto& [key, value] : table)
+    {
+        [[maybe_unused]] const toml::node& node = value;
+        if(!CONFIG_KEYS.contains(std::string(key.str())))
+        {
+            return std::unexpected(mw::runtimeError(
+                "Unknown configuration key '" +
+                std::string(key.str()) + "'"));
+        }
+    }
+
+    auto base_url_text = requiredString(table, "base_url");
+    auto listen_address_text = requiredString(table, "listen_address");
+    auto static_root_text = requiredString(table, "static_root");
+    auto database_path_text = requiredString(table, "database_path");
+    auto card_storage_text = requiredString(table, "card_storage_root");
+    auto avif_quality = requiredInteger(table, "avif_quality");
+    auto thumbnail_long_side = requiredInteger(
+        table, "thumbnail_long_side");
+    if(!base_url_text || !listen_address_text || !static_root_text ||
+       !database_path_text || !card_storage_text || !avif_quality ||
+       !thumbnail_long_side)
+    {
+        if(!base_url_text)
+        {
+            return std::unexpected(std::move(base_url_text.error()));
+        }
+        if(!listen_address_text)
+        {
+            return std::unexpected(std::move(listen_address_text.error()));
+        }
+        if(!static_root_text)
+        {
+            return std::unexpected(std::move(static_root_text.error()));
+        }
+        if(!database_path_text)
+        {
+            return std::unexpected(std::move(database_path_text.error()));
+        }
+        if(!card_storage_text)
+        {
+            return std::unexpected(std::move(card_storage_text.error()));
+        }
+        if(!avif_quality)
+        {
+            return std::unexpected(std::move(avif_quality.error()));
+        }
+        return std::unexpected(std::move(thumbnail_long_side.error()));
+    }
+
+    auto base_url = mw::URL::fromStr(*base_url_text);
+    if(!base_url ||
+       (base_url->scheme() != "http" && base_url->scheme() != "https") ||
+       base_url->host().empty())
+    {
+        return std::unexpected(mw::runtimeError(
+            "base_url must be an absolute HTTP or HTTPS URL"));
+    }
+    std::string base_path = base_url->path();
+    if(base_path.empty())
+    {
+        base_path = "/";
+    }
+    while(base_path.size() > 1 && base_path.ends_with('/'))
+    {
+        base_path.pop_back();
+    }
+    if(!base_path.ends_with('/'))
+    {
+        base_path.push_back('/');
+    }
+    base_url->path(base_path.c_str());
+
+    std::optional<mw::HTTPServer::ListenAddress> listen_address;
+    if(listen_address_text->starts_with("unix:"))
+    {
+        const std::string socket_path = listen_address_text->substr(5);
+        if(socket_path.empty())
+        {
+            return std::unexpected(mw::runtimeError(
+                "A Unix listen address requires a socket path"));
+        }
+        listen_address.emplace(mw::SocketFileInfo(socket_path));
+    }
+    else
+    {
+        auto listen_port = requiredInteger(table, "listen_port");
+        if(!listen_port || *listen_port < 1 || *listen_port > 65535)
+        {
+            return std::unexpected(mw::runtimeError(
+                "listen_port must be an integer from 1 through 65535"));
+        }
+        listen_address.emplace(mw::IPSocketInfo{
+            *listen_address_text, static_cast<int>(*listen_port)});
+    }
+    if(*avif_quality < 0 || *avif_quality > 100)
+    {
+        return std::unexpected(mw::runtimeError(
+            "avif_quality must be from 0 through 100"));
+    }
+    if(*thumbnail_long_side < 1 ||
+       *thumbnail_long_side > UINT32_MAX)
+    {
+        return std::unexpected(mw::runtimeError(
+            "thumbnail_long_side must be a positive 32-bit integer"));
+    }
+
+    const std::filesystem::path config_directory =
+        std::filesystem::absolute(config_path).parent_path();
+    const std::filesystem::path static_root = normalizedPath(
+        config_directory, *static_root_text);
+    const std::filesystem::path database_path = normalizedPath(
+        config_directory, *database_path_text);
+    const std::filesystem::path card_storage_root = normalizedPath(
+        config_directory, *card_storage_text);
+    std::error_code filesystem_error;
+    if(!std::filesystem::is_directory(static_root, filesystem_error) ||
+       filesystem_error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "static_root must name an existing directory"));
+    }
+    if(pathContains(static_root, card_storage_root) ||
+       pathContains(card_storage_root, static_root))
+    {
+        return std::unexpected(mw::runtimeError(
+            "static_root and card_storage_root must not overlap"));
+    }
+    if(pathContains(static_root, database_path) ||
+       pathContains(card_storage_root, database_path))
+    {
+        return std::unexpected(mw::runtimeError(
+            "database_path must not be inside a mounted asset root"));
+    }
+
+    auto database_directory = createDirectory(
+        database_path.parent_path(), "the database directory");
+    if(!database_directory)
+    {
+        return std::unexpected(std::move(database_directory.error()));
+    }
+    auto card_directory = createDirectory(
+        card_storage_root, "the card storage directory");
+    if(!card_directory)
+    {
+        return std::unexpected(std::move(card_directory.error()));
+    }
+    auto published_directory = createDirectory(
+        card_storage_root / "published", "the published card directory");
+    if(!published_directory)
+    {
+        return std::unexpected(std::move(published_directory.error()));
+    }
+
+    return Config{
+        std::move(*base_url),
+        std::move(*listen_address),
+        static_root,
+        database_path,
+        card_storage_root,
+        static_cast<int>(*avif_quality),
+        static_cast<std::uint32_t>(*thumbnail_long_side),
+    };
+}
diff --git a/src/config.h b/src/config.h
index 982d3ce..89174fd 100644
--- a/src/config.h
+++ b/src/config.h
@@ -4,6 +4,7 @@
 #include <filesystem>
 
 #include <mw/http_server.hpp>
+#include <mw/error.hpp>
 #include <mw/url.hpp>
 
 /// Validated process configuration.
@@ -30,3 +31,6 @@ struct Config
     /// Long-side pixel count used for generated thumbnails.
     std::uint32_t thumbnail_long_side;
 };
+
+/// Load, validate, and normalize one TOML process configuration.
+mw::E<Config> loadConfig(const std::filesystem::path& config_path);
diff --git a/src/data.h b/src/data.h
index 8bad276..bd397a5 100644
--- a/src/data.h
+++ b/src/data.h
@@ -10,12 +10,7 @@
 
 #include "card.h"
 #include "game.h"
-
-/// Polymorphic validated metadata owned by a compiled game.
-class GameCardMetadata;
-
-/// Compiled behavior and metadata schema for one game.
-class GameDefinition;
+#include "game_definition.h"
 
 /// Immutable collection of compiled game definitions.
 class GameRegistry;
@@ -108,6 +103,11 @@ public:
         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;
 
diff --git a/src/data_fake.cpp b/src/data_fake.cpp
index f972541..674c68c 100644
--- a/src/data_fake.cpp
+++ b/src/data_fake.cpp
@@ -84,6 +84,13 @@ mw::E<std::vector<DisplayField>> DataSourceFake::getGameDisplayFields(
     return fields->second;
 }
 
+mw::E<FormFields> DataSourceFake::getGameFormValues(
+    [[maybe_unused]] const GameDefinition& game,
+    [[maybe_unused]] std::int64_t card_id) const
+{
+    return FormFields{};
+}
+
 mw::E<std::vector<Series>> DataSourceFake::getSeries() const
 {
     return series_;
diff --git a/src/data_fake.h b/src/data_fake.h
index a050f2f..13db8e0 100644
--- a/src/data_fake.h
+++ b/src/data_fake.h
@@ -45,6 +45,11 @@ public:
         const GameDefinition& game,
         std::int64_t card_id) const override;
 
+    /// Return no editable game values from the read-only fake.
+    mw::E<FormFields> getGameFormValues(
+        const GameDefinition& game,
+        std::int64_t card_id) const override;
+
     /// Return all configured series in insertion order.
     mw::E<std::vector<Series>> getSeries() const override;
 
diff --git a/src/data_sqlite.cpp b/src/data_sqlite.cpp
index 097f924..ae48098 100644
--- a/src/data_sqlite.cpp
+++ b/src/data_sqlite.cpp
@@ -13,15 +13,11 @@
 
 #include <spdlog/spdlog.h>
 
-namespace
-{
+#include "game_definition.h"
+#include "game_registry.h"
 
-mw::Error notImplemented(std::string_view operation)
+namespace
 {
-    return mw::runtimeError(
-        "DataSourceSQLite has not implemented " +
-        std::string(operation) + ".");
-}
 
 mw::E<void> rollbackWithError(
     mw::SQLite& connection,
@@ -324,15 +320,19 @@ public:
                 return std::unexpected(mw::runtimeError(
                     "A game card requires compiled game metadata"));
             }
-            return std::unexpected(notImplemented(
-                "game-specific card insertion"));
+            if(game->shortName() != *card.identity.game_short_name)
+            {
+                return std::unexpected(mw::runtimeError(
+                    "Card and compiled game identities differ"));
+            }
         }
-        if(game != nullptr)
+        else if(game != nullptr || !series_ids.empty())
         {
             return std::unexpected(mw::runtimeError(
-                "A loose card cannot have compiled game metadata"));
+                "A loose card cannot have game metadata or series"));
         }
-        if(card.identity.card_number >
+        if(!card.identity.game_short_name &&
+           card.identity.card_number >
            std::numeric_limits<std::uint32_t>::max())
         {
             return std::unexpected(mw::runtimeError(
@@ -371,6 +371,16 @@ public:
         }
         const std::int64_t card_id = connection_.lastInsertRowID();
 
+        if(game != nullptr)
+        {
+            auto game_insert = game->insertMetadata(
+                connection_, card_id, *metadata);
+            if(!game_insert)
+            {
+                return std::unexpected(std::move(game_insert.error()));
+            }
+        }
+
         for(std::int64_t series_id : series_ids)
         {
             auto membership = connection_.statementFromStr(
@@ -412,19 +422,33 @@ public:
             return std::unexpected(mw::runtimeError(
                 "An updated card requires an internal ID"));
         }
-        if(card.identity.game_short_name || game != nullptr ||
-           metadata != nullptr || !series_ids.empty())
+        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())
         {
-            return std::unexpected(notImplemented(
-                "game-specific card updates"));
+            return std::unexpected(mw::runtimeError(
+                "A loose card cannot have game metadata or series"));
         }
 
         auto statement = connection_.statementFromStr(
             "UPDATE cards SET name = ?, short_description = ?, "
             "long_description = ?, rarity = ?, front_extension = ?, "
             "foil_extension = ?, thumbnail_extension = ?, revision = ? "
-            "WHERE id = ? AND game_short_name IS NULL "
-            "AND card_number = ?;");
+            "WHERE id = ? AND "
+            "((? IS NULL AND game_short_name IS NULL) OR "
+            "game_short_name = ?) AND card_number = ?;");
         if(!statement)
         {
             return std::unexpected(std::move(statement.error()));
@@ -439,6 +463,8 @@ public:
             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)
         {
@@ -460,35 +486,146 @@ public:
             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)
+        {
+            return std::unexpected(std::move(delete_bind.error()));
+        }
+        auto deleted = connection_.execute(std::move(*delete_memberships));
+        if(!deleted)
+        {
+            return std::unexpected(std::move(deleted.error()));
+        }
+        for(std::int64_t series_id : series_ids)
+        {
+            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 {};
     }
 
     /// Delete a card and its dependent database rows.
     mw::E<void> deleteCard(
-        [[maybe_unused]] std::int64_t card_id) override
+        std::int64_t card_id) override
     {
-        return std::unexpected(notImplemented("card deletion"));
+        auto statement = connection_.statementFromStr(
+            "DELETE FROM cards WHERE 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()));
+        }
+        return connection_.execute(std::move(*statement));
     }
 
     /// Insert a series and return its internal ID.
     mw::E<std::int64_t> insertSeries(
-        [[maybe_unused]] const Series& series) override
+        const Series& series) override
     {
-        return std::unexpected(notImplemented("series insertion"));
+        if(series.id != 0)
+        {
+            return std::unexpected(mw::runtimeError(
+                "A new series cannot already have an internal ID"));
+        }
+        auto statement = connection_.statementFromStr(
+            "INSERT INTO series (game_short_name, name, description) "
+            "VALUES (?, ?, ?);");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(
+            series.game_short_name, series.name, series.description);
+        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 connection_.lastInsertRowID();
     }
 
     /// Replace a series name and description without changing its game.
     mw::E<void> updateSeries(
-        [[maybe_unused]] const Series& series) override
+        const Series& series) override
     {
-        return std::unexpected(notImplemented("series updates"));
+        auto statement = connection_.statementFromStr(
+            "UPDATE series SET name = ?, description = ? "
+            "WHERE id = ? AND game_short_name = ?;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(
+            series.name,
+            series.description,
+            series.id,
+            series.game_short_name);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        return connection_.execute(std::move(*statement));
     }
 
     /// Delete a series and its membership rows.
     mw::E<void> deleteSeries(
-        [[maybe_unused]] std::int64_t series_id) override
+        std::int64_t series_id) override
     {
-        return std::unexpected(notImplemented("series deletion"));
+        auto statement = connection_.statementFromStr(
+            "DELETE FROM series WHERE id = ?;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind<std::int64_t>(series_id);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        return connection_.execute(std::move(*statement));
     }
 
     /// Commit the transaction and release its connection lock.
@@ -536,6 +673,19 @@ mw::E<std::unique_ptr<DataSourceSQLite>> DataSourceSQLite::fromFile(
     {
         return std::unexpected(std::move(synchronous_result.error()));
     }
+    const std::array<std::string_view, 3> pragmas = {
+        "PRAGMA foreign_keys = ON;",
+        "PRAGMA journal_mode = WAL;",
+        "PRAGMA busy_timeout = 5000;",
+    };
+    for(std::string_view pragma : pragmas)
+    {
+        auto result = (*connection)->execute(std::string(pragma));
+        if(!result)
+        {
+            return std::unexpected(std::move(result.error()));
+        }
+    }
     return std::unique_ptr<DataSourceSQLite>(
         new DataSourceSQLite(std::move(*connection)));
 }
@@ -547,7 +697,7 @@ mw::E<std::int64_t> DataSourceSQLite::getSchemaVersion() const
 }
 
 mw::E<void> DataSourceSQLite::migrateSchema0To1(
-    [[maybe_unused]] const GameRegistry& games)
+    const GameRegistry& games)
 {
     std::lock_guard lock(mutex_);
     auto version = connection_->evalToValue<std::int64_t>(
@@ -576,6 +726,15 @@ 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)
@@ -748,21 +907,85 @@ mw::E<std::optional<Card>> DataSourceSQLite::getCard(
 
 mw::E<std::vector<DisplayField>>
 DataSourceSQLite::getGameDisplayFields(
-    [[maybe_unused]] const GameDefinition& game,
-    [[maybe_unused]] std::int64_t card_id) const
+    const GameDefinition& game,
+    std::int64_t card_id) const
 {
-    return std::unexpected(notImplemented("game display-field reads"));
+    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
 {
-    return std::unexpected(notImplemented("series index reads"));
+    std::lock_guard lock(mutex_);
+    auto rows = connection_->eval<
+        std::int64_t,
+        std::string,
+        std::string,
+        std::string>(
+            "SELECT id, game_short_name, name, description "
+            "FROM series ORDER BY game_short_name, name, id;");
+    if(!rows)
+    {
+        return std::unexpected(std::move(rows.error()));
+    }
+    std::vector<Series> result;
+    result.reserve(rows->size());
+    for(auto& [id, game, name, description] : *rows)
+    {
+        result.push_back({
+            id,
+            std::move(game),
+            std::move(name),
+            std::move(description),
+        });
+    }
+    return result;
 }
 
 mw::E<std::optional<Series>> DataSourceSQLite::getSeries(
-    [[maybe_unused]] std::int64_t series_id) const
+    std::int64_t series_id) const
 {
-    return std::unexpected(notImplemented("series reads"));
+    std::lock_guard lock(mutex_);
+    auto statement = connection_->statementFromStr(
+        "SELECT id, game_short_name, name, description "
+        "FROM series WHERE id = ?;");
+    if(!statement)
+    {
+        return std::unexpected(std::move(statement.error()));
+    }
+    auto bind = statement->bind<std::int64_t>(series_id);
+    if(!bind)
+    {
+        return std::unexpected(std::move(bind.error()));
+    }
+    auto rows = connection_->eval<
+        std::int64_t,
+        std::string,
+        std::string,
+        std::string>(std::move(*statement));
+    if(!rows)
+    {
+        return std::unexpected(std::move(rows.error()));
+    }
+    if(rows->empty())
+    {
+        return std::optional<Series>{};
+    }
+    auto& [id, game, name, description] = rows->front();
+    return std::optional<Series>(Series{
+        id,
+        std::move(game),
+        std::move(name),
+        std::move(description),
+    });
 }
 
 mw::E<std::vector<std::int64_t>> DataSourceSQLite::getCardSeries(
@@ -798,7 +1021,23 @@ mw::E<std::vector<std::int64_t>> DataSourceSQLite::getCardSeries(
 mw::E<std::vector<std::string>>
 DataSourceSQLite::getPersistedGameNames() const
 {
-    return std::unexpected(notImplemented("persisted-game reads"));
+    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(
diff --git a/src/data_sqlite.h b/src/data_sqlite.h
index 9b97850..aca4c42 100644
--- a/src/data_sqlite.h
+++ b/src/data_sqlite.h
@@ -48,6 +48,11 @@ public:
         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;
 
diff --git a/src/game_definition.h b/src/game_definition.h
new file mode 100644
index 0000000..44b9e25
--- /dev/null
+++ b/src/game_definition.h
@@ -0,0 +1,92 @@
+#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_registry.cpp b/src/game_registry.cpp
new file mode 100644
index 0000000..38edc25
--- /dev/null
+++ b/src/game_registry.cpp
@@ -0,0 +1,65 @@
+#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
index 5dd744b..b9c7569 100644
--- a/src/game_registry.h
+++ b/src/game_registry.h
@@ -1,12 +1,32 @@
 #pragma once
 
-/// Immutable collection of compiled game definitions.
-///
-/// Production currently registers no games. Lookup and schema-extension
-/// behavior will be added with the first compiled game definition.
+#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 the empty production registry.
-    GameRegistry() = default;
+    /// 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/main.cpp b/src/main.cpp
index 473b719..f5de9a2 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,7 +1,8 @@
 #include <array>
 #include <chrono>
 #include <csignal>
-#include <filesystem>
+#include <iostream>
+#include <memory>
 #include <string>
 #include <thread>
 #include <utility>
@@ -16,9 +17,6 @@
 namespace
 {
 
-/// Development HTTP port used until configuration loading is implemented.
-inline constexpr int DEVELOPMENT_PORT = 8080;
-
 /// Signal-safe flag requesting shutdown from the main loop.
 volatile std::sig_atomic_t STOP_REQUESTED = 0;
 
@@ -61,44 +59,36 @@ mw::E<void> prepareImageMagick()
     return {};
 }
 
-mw::E<Config> makeDevelopmentConfig()
+void printUsage(std::ostream& output, const char* program)
 {
-    const std::string base_url_text =
-        "http://127.0.0.1:" + std::to_string(DEVELOPMENT_PORT) + "/";
-    auto base_url = mw::URL::fromStr(base_url_text);
-    if(!base_url)
-    {
-        return std::unexpected(std::move(base_url.error()));
-    }
-
-    const std::filesystem::path source_root = CARD_COLLECTION_SOURCE_DIR;
-    const std::filesystem::path card_storage_root = source_root / "var/cards";
-    std::error_code filesystem_error;
-    std::filesystem::create_directories(
-        card_storage_root / "published", filesystem_error);
-    if(filesystem_error)
-    {
-        return std::unexpected(mw::runtimeError(
-            "Failed to create the development card directory: " +
-            filesystem_error.message()));
-    }
-
-    return Config{
-        std::move(*base_url),
-        mw::IPSocketInfo{"127.0.0.1", DEVELOPMENT_PORT},
-        source_root / "static",
-        source_root / "var/card_collection.sqlite3",
-        card_storage_root,
-        75,
-        256,
-    };
+    output << "Usage: " << program << " <config.toml>\n";
 }
 
 } // namespace
 
-/// Start the Card Collection development server with its SQLite database.
+/// Start Card Collection from one validated configuration file.
 int main(int argc, char** argv)
 {
+    const char* program = argc > 0 ? argv[0] : "card_collection";
+    if(argc == 2 && std::string(argv[1]) == "--help")
+    {
+        printUsage(std::cout, program);
+        return 0;
+    }
+    if(argc != 2)
+    {
+        printUsage(std::cerr, program);
+        return 2;
+    }
+
+    auto config = loadConfig(argv[1]);
+    if(!config)
+    {
+        std::cerr << "Failed to load configuration: "
+                  << config.error().msg() << '\n';
+        return 1;
+    }
+
     Magick::InitializeMagick(argc > 0 ? argv[0] : nullptr);
     auto image_magick = prepareImageMagick();
     if(!image_magick)
@@ -109,26 +99,31 @@ int main(int argc, char** argv)
         return 1;
     }
 
-    auto config = makeDevelopmentConfig();
-    if(!config)
+    auto games = std::make_unique<GameRegistry>();
+    auto data_source = prepareDataSource(config->database_path, *games);
+    if(!data_source)
     {
         spdlog::error(
-            "Failed to configure the server: {}",
-            config.error().msg());
+            "Failed to prepare the card database: {}",
+            data_source.error().msg());
         return 1;
     }
 
-    GameRegistry games;
-    auto data_source = prepareDataSource(config->database_path, games);
-    if(!data_source)
+    AssetStore assets(config->card_storage_root);
+    auto reconciled = assets.reconcile(**data_source);
+    if(!reconciled)
     {
         spdlog::error(
-            "Failed to prepare the card database: {}",
-            data_source.error().msg());
+            "Failed to reconcile card storage: {}",
+            reconciled.error().msg());
         return 1;
     }
 
-    App app(*config, std::move(*data_source));
+    App app(
+        *config,
+        std::move(*data_source),
+        std::move(games),
+        std::make_unique<NonSecretRandom>());
 
     std::signal(SIGINT, handleSignal);
     std::signal(SIGTERM, handleSignal);
@@ -142,9 +137,7 @@ int main(int argc, char** argv)
         return 1;
     }
 
-    spdlog::info(
-        "Development server listening at http://127.0.0.1:{}/",
-        DEVELOPMENT_PORT);
+    spdlog::info("Server listening at {}", config->base_url.str());
     while(STOP_REQUESTED == 0)
     {
         std::this_thread::sleep_for(std::chrono::milliseconds(100));
diff --git a/src/markdown_renderer.cpp b/src/markdown_renderer.cpp
new file mode 100644
index 0000000..83a201b
--- /dev/null
+++ b/src/markdown_renderer.cpp
@@ -0,0 +1,145 @@
+#include "markdown_renderer.h"
+
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <variant>
+
+#include <macrodown.h>
+#include <mw/url.hpp>
+#include <mw/utils.hpp>
+#include <nodes.h>
+
+namespace
+{
+
+std::optional<std::string> literalText(const macrodown::Node& node)
+{
+    if(const auto* text = std::get_if<macrodown::Text>(&node.data))
+    {
+        return text->content;
+    }
+    const auto* group = std::get_if<macrodown::Group>(&node.data);
+    if(group == nullptr)
+    {
+        return std::nullopt;
+    }
+    std::string result;
+    for(const std::unique_ptr<macrodown::Node>& child : group->children)
+    {
+        auto child_text = literalText(*child);
+        if(!child_text)
+        {
+            return std::nullopt;
+        }
+        result += *child_text;
+    }
+    return result;
+}
+
+mw::E<void> validateUrls(const macrodown::Node& node)
+{
+    if(const auto* macro = std::get_if<macrodown::Macro>(&node.data))
+    {
+        if(macro->name == "link" || macro->name == "img")
+        {
+            if(macro->arguments.empty())
+            {
+                return std::unexpected(mw::httpError(
+                    422, "Markdown links require an absolute URL"));
+            }
+            auto text = literalText(*macro->arguments.front());
+            auto url = text ? mw::URL::fromStr(*text)
+                            : mw::E<mw::URL>(std::unexpected(
+                                  mw::runtimeError("Computed URL")));
+            if(!text || !url ||
+               (url->scheme() != "http" && url->scheme() != "https") ||
+               url->host().empty())
+            {
+                return std::unexpected(mw::httpError(
+                    422,
+                    "Markdown links and images require literal HTTP or "
+                    "HTTPS URLs"));
+            }
+        }
+        for(const std::unique_ptr<macrodown::Node>& argument :
+            macro->arguments)
+        {
+            auto valid = validateUrls(*argument);
+            if(!valid)
+            {
+                return valid;
+            }
+        }
+    }
+    else if(const auto* group = std::get_if<macrodown::Group>(&node.data))
+    {
+        for(const std::unique_ptr<macrodown::Node>& child : group->children)
+        {
+            auto valid = validateUrls(*child);
+            if(!valid)
+            {
+                return valid;
+            }
+        }
+    }
+    return {};
+}
+
+void escapeText(macrodown::Node& node)
+{
+    if(auto* text = std::get_if<macrodown::Text>(&node.data))
+    {
+        text->content = mw::escapeHTML(text->content);
+    }
+    else if(auto* macro = std::get_if<macrodown::Macro>(&node.data))
+    {
+        for(const std::unique_ptr<macrodown::Node>& argument :
+            macro->arguments)
+        {
+            escapeText(*argument);
+        }
+    }
+    else if(auto* group = std::get_if<macrodown::Group>(&node.data))
+    {
+        for(const std::unique_ptr<macrodown::Node>& child : group->children)
+        {
+            escapeText(*child);
+        }
+    }
+}
+
+} // namespace
+
+RenderedHtml::RenderedHtml(std::string value)
+        : value_(std::move(value))
+{}
+
+const std::string& RenderedHtml::value() const
+{
+    return value_;
+}
+
+mw::E<RenderedHtml> MarkdownRenderer::render(
+    const std::string& source) const
+{
+    try
+    {
+        macrodown::MacroDown renderer;
+        std::unique_ptr<macrodown::Node> tree = renderer.parse(source);
+        auto valid = validateUrls(*tree);
+        if(!valid)
+        {
+            return std::unexpected(std::move(valid.error()));
+        }
+        escapeText(*tree);
+        return RenderedHtml(renderer.render(*tree));
+    }
+    catch(const std::exception& error)
+    {
+        return std::unexpected(mw::httpError(
+            422, "Markdown could not be rendered: " +
+            std::string(error.what())));
+    }
+}
diff --git a/src/markdown_renderer.h b/src/markdown_renderer.h
new file mode 100644
index 0000000..048f23f
--- /dev/null
+++ b/src/markdown_renderer.h
@@ -0,0 +1,27 @@
+#pragma once
+
+#include <string>
+
+#include <mw/error.hpp>
+
+/// Trusted HTML produced only by the validated Markdown renderer.
+class RenderedHtml
+{
+public:
+    /// Construct a trusted fragment from validated renderer output.
+    explicit RenderedHtml(std::string value);
+
+    /// Return the trusted HTML fragment.
+    const std::string& value() const;
+
+private:
+    std::string value_;
+};
+
+/// Render MacroDown documents while rejecting unsafe URLs and raw HTML.
+class MarkdownRenderer
+{
+public:
+    /// Validate and render one Markdown document.
+    mw::E<RenderedHtml> render(const std::string& source) const;
+};
diff --git a/src/multipart_reader.cpp b/src/multipart_reader.cpp
index ab2af04..c5cdb10 100644
--- a/src/multipart_reader.cpp
+++ b/src/multipart_reader.cpp
@@ -24,6 +24,10 @@ std::atomic<std::uint64_t> NEXT_STAGING_ID = 0;
 
 bool isTextField(std::string_view name)
 {
+    if(name.starts_with("game.") && name.size() > 5)
+    {
+        return true;
+    }
     static const std::unordered_set<std::string> names = {
         "game",
         "name",
diff --git a/src/non_secret_random.cpp b/src/non_secret_random.cpp
new file mode 100644
index 0000000..c51a922
--- /dev/null
+++ b/src/non_secret_random.cpp
@@ -0,0 +1,46 @@
+#include "non_secret_random.h"
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <random>
+#include <string>
+
+NonSecretRandom::NonSecretRandom()
+{
+    std::random_device device;
+    std::array<std::uint32_t, 8> seeds;
+    for(std::uint32_t& seed : seeds)
+    {
+        seed = device();
+    }
+    std::seed_seq sequence(seeds.begin(), seeds.end());
+    engine_.seed(sequence);
+}
+
+NonSecretRandom::NonSecretRandom(std::uint64_t seed)
+        : engine_(seed)
+{}
+
+std::uint32_t NonSecretRandom::next()
+{
+    std::lock_guard lock(mutex_);
+    std::uniform_int_distribution<std::uint32_t> distribution;
+    return distribution(engine_);
+}
+
+std::string NonSecretRandom::hex(std::size_t byte_count)
+{
+    static constexpr char HEX_DIGITS[] = "0123456789abcdef";
+    std::lock_guard lock(mutex_);
+    std::uniform_int_distribution<unsigned int> distribution(0, 255);
+    std::string result;
+    result.reserve(byte_count * 2);
+    for(std::size_t index = 0; index < byte_count; ++index)
+    {
+        const unsigned int byte = distribution(engine_);
+        result.push_back(HEX_DIGITS[byte >> 4]);
+        result.push_back(HEX_DIGITS[byte & 0x0f]);
+    }
+    return result;
+}
diff --git a/src/non_secret_random.h b/src/non_secret_random.h
new file mode 100644
index 0000000..352a31d
--- /dev/null
+++ b/src/non_secret_random.h
@@ -0,0 +1,28 @@
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <mutex>
+#include <random>
+#include <string>
+
+/// Thread-safe pseudorandom values that must never be used as secrets.
+class NonSecretRandom
+{
+public:
+    /// Seed a production generator from std::random_device.
+    NonSecretRandom();
+
+    /// Seed a deterministic generator for tests.
+    explicit NonSecretRandom(std::uint64_t seed);
+
+    /// Return one uniformly distributed 32-bit value.
+    std::uint32_t next();
+
+    /// Return pseudorandom bytes encoded as lowercase hexadecimal.
+    std::string hex(std::size_t byte_count);
+
+private:
+    std::mutex mutex_;
+    std::mt19937_64 engine_;
+};
diff --git a/src/series_service.cpp b/src/series_service.cpp
new file mode 100644
index 0000000..61e277a
--- /dev/null
+++ b/src/series_service.cpp
@@ -0,0 +1,169 @@
+#include "series_service.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <string>
+#include <utility>
+
+#include <mw/utils.hpp>
+
+namespace
+{
+
+mw::E<void> rejectDuplicate(
+    DataSourceInterface& data_source,
+    const Series& candidate)
+{
+    auto series = data_source.getSeries();
+    if(!series)
+    {
+        return std::unexpected(std::move(series.error()));
+    }
+    const bool duplicate = std::ranges::any_of(
+        *series,
+        [&candidate](const Series& existing)
+        {
+            return existing.id != candidate.id &&
+                existing.game_short_name == candidate.game_short_name &&
+                existing.name == candidate.name;
+        });
+    if(duplicate)
+    {
+        return std::unexpected(mw::httpError(
+            409, "A series with this name already exists for the game"));
+    }
+    return {};
+}
+
+} // namespace
+
+SeriesService::SeriesService(
+    DataSourceInterface& data_source,
+    const GameRegistry& games)
+        : data_source_(data_source),
+          games_(games)
+{}
+
+mw::E<std::int64_t> SeriesService::create(
+    std::string game_short_name,
+    std::string name,
+    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"));
+    }
+    if(name.size() > 200)
+    {
+        return std::unexpected(mw::httpError(422, "Series name is too long"));
+    }
+    auto rendered = markdown_renderer_.render(description);
+    if(!rendered)
+    {
+        return std::unexpected(std::move(rendered.error()));
+    }
+    Series series = {
+        0,
+        std::move(game_short_name),
+        std::move(name),
+        std::move(description),
+    };
+    auto unique = rejectDuplicate(data_source_, series);
+    if(!unique)
+    {
+        return std::unexpected(std::move(unique.error()));
+    }
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto id = (*transaction)->insertSeries(series);
+    if(!id)
+    {
+        return std::unexpected(std::move(id.error()));
+    }
+    auto committed = (*transaction)->commit();
+    if(!committed)
+    {
+        return std::unexpected(std::move(committed.error()));
+    }
+    return *id;
+}
+
+mw::E<void> SeriesService::update(
+    std::int64_t series_id,
+    std::string name,
+    std::string description)
+{
+    auto current = data_source_.getSeries(series_id);
+    if(!current)
+    {
+        return std::unexpected(std::move(current.error()));
+    }
+    if(!*current)
+    {
+        return std::unexpected(mw::httpError(404, "Series not found"));
+    }
+    Series series = std::move(**current);
+    series.name = std::string(mw::strip(name));
+    series.description = std::move(description);
+    if(series.name.empty())
+    {
+        return std::unexpected(mw::httpError(422, "Series name is required"));
+    }
+    if(series.name.size() > 200)
+    {
+        return std::unexpected(mw::httpError(422, "Series name is too long"));
+    }
+    auto rendered = markdown_renderer_.render(series.description);
+    if(!rendered)
+    {
+        return std::unexpected(std::move(rendered.error()));
+    }
+    auto unique = rejectDuplicate(data_source_, series);
+    if(!unique)
+    {
+        return std::unexpected(std::move(unique.error()));
+    }
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto updated = (*transaction)->updateSeries(series);
+    if(!updated)
+    {
+        return std::unexpected(std::move(updated.error()));
+    }
+    return (*transaction)->commit();
+}
+
+mw::E<void> SeriesService::remove(std::int64_t series_id)
+{
+    auto current = data_source_.getSeries(series_id);
+    if(!current)
+    {
+        return std::unexpected(std::move(current.error()));
+    }
+    if(!*current)
+    {
+        return std::unexpected(mw::httpError(404, "Series not found"));
+    }
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto deleted = (*transaction)->deleteSeries(series_id);
+    if(!deleted)
+    {
+        return std::unexpected(std::move(deleted.error()));
+    }
+    return (*transaction)->commit();
+}
diff --git a/src/series_service.h b/src/series_service.h
new file mode 100644
index 0000000..562cab7
--- /dev/null
+++ b/src/series_service.h
@@ -0,0 +1,40 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+
+#include <mw/error.hpp>
+
+#include "data.h"
+#include "game_registry.h"
+#include "markdown_renderer.h"
+
+/// Validate and coordinate dynamic series mutations.
+class SeriesService
+{
+public:
+    /// Construct a series service from its persistence and game boundaries.
+    SeriesService(
+        DataSourceInterface& data_source,
+        const GameRegistry& games);
+
+    /// Create a series and return its internal ID.
+    mw::E<std::int64_t> create(
+        std::string game_short_name,
+        std::string name,
+        std::string description);
+
+    /// Edit a series without changing its owning game.
+    mw::E<void> update(
+        std::int64_t series_id,
+        std::string name,
+        std::string description);
+
+    /// Delete a series and its membership rows.
+    mw::E<void> remove(std::int64_t series_id);
+
+private:
+    DataSourceInterface& data_source_;
+    const GameRegistry& games_;
+    MarkdownRenderer markdown_renderer_;
+};
diff --git a/src/startup.cpp b/src/startup.cpp
index 8646751..ba820f0 100644
--- a/src/startup.cpp
+++ b/src/startup.cpp
@@ -3,6 +3,8 @@
 #include <utility>
 
 #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,
@@ -18,5 +20,40 @@ mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
     {
         return std::unexpected(std::move(migration_result.error()));
     }
+
+    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/static/card_form.js b/static/card_form.js
index a73aa1e..4f56ae7 100644
--- a/static/card_form.js
+++ b/static/card_form.js
@@ -45,14 +45,41 @@ function updateSourceMode()
     const foil_file = document.getElementById("CardFoilFile");
     const front_url = document.getElementById("CardFrontUrl");
     const foil_url = document.getElementById("CardFoilUrl");
-    front_file.disabled = !uses_files || !replacesFront();
-    foil_file.disabled = !uses_files || !replacesFoil();
-    front_url.disabled = uses_files || !replacesFront();
-    foil_url.disabled = uses_files || !replacesFoil();
-    front_file.required = uses_files && replacesFront();
-    front_url.required = !uses_files && replacesFront();
-    foil_file.required = uses_files && isEditForm() && replacesFoil();
-    foil_url.required = !uses_files && isEditForm() && replacesFoil();
+    const state = window.cardFormLogic.sourceInputState({
+        mode: isEditForm() ? "edit" : "create",
+        source_mode: selected_mode,
+        front_action: replacesFront() ? "replace" : "keep",
+        foil_action: replacesFoil() ? "replace" : "keep",
+    });
+    front_file.disabled = !state.front_file_enabled;
+    foil_file.disabled = !state.foil_file_enabled;
+    front_url.disabled = !state.front_url_enabled;
+    foil_url.disabled = !state.foil_url_enabled;
+    front_file.required = state.front_file_enabled && state.front_required;
+    front_url.required = state.front_url_enabled && state.front_required;
+    foil_file.required = state.foil_file_enabled && state.foil_required;
+    foil_url.required = state.foil_url_enabled && state.foil_required;
+}
+
+/** Show only series belonging to the selected compiled game. */
+function updateSeriesChoices()
+{
+    const game = document.getElementById("CardGame").value;
+    for(const group of document.querySelectorAll(".game-fields"))
+    {
+        const visible = group.dataset.game == game;
+        group.hidden = !visible;
+        for(const input of group.querySelectorAll("input"))
+        {
+            input.disabled = !visible;
+        }
+    }
+    for(const choice of document.querySelectorAll(".series-choice"))
+    {
+        const visible = game != "" && choice.dataset.game == game;
+        choice.hidden = !visible;
+        choice.querySelector("input").disabled = !visible;
+    }
 }
 
 /** Display the selected image filename beside one file input. */
@@ -135,11 +162,11 @@ async function updateLocalPreview()
 /** Fetch one CORS-enabled URL into its corresponding file input. */
 async function fetchImageInput(url_input, file_input, filename)
 {
-    const url = new URL(url_input.value);
-    if(url.protocol != "http:" && url.protocol != "https:")
+    if(!window.cardFormLogic.validRemoteImageUrl(url_input.value))
     {
         throw(new Error("Image URLs must use HTTP or HTTPS."));
     }
+    const url = new URL(url_input.value);
     const response = await fetch(url, {
         mode: "cors",
         credentials: "omit",
@@ -211,11 +238,16 @@ async function prepareCardSubmission(event)
         {
             await updateLocalPreview();
         }
-        const resulting_foil = foil_action != "remove" &&
-            (foil_action == "replace"
-                ? foil_input.files.length != 0
-                : page_data.foil_url != null);
-        if(rendering_changed && resulting_foil)
+        const needs_thumbnail = window.cardFormLogic.foilThumbnailRequired({
+            mode: page_data.mode,
+            front_action: page_data.mode == "edit"
+                ? document.getElementById("CardFrontAction").value
+                : "replace",
+            foil_action,
+            has_current_foil: page_data.foil_url != null,
+            has_replacement_foil: foil_input.files.length != 0,
+        });
+        if(needs_thumbnail)
         {
             if(window.cardPreview == null)
             {
@@ -288,6 +320,12 @@ function initializeCardForm()
                 updateLocalPreview().catch(console.error);
             });
     }
+    else
+    {
+        document.getElementById("CardGame").addEventListener(
+            "change", updateSeriesChoices);
+        updateSeriesChoices();
+    }
     document.getElementById("CardForm").addEventListener(
         "submit", prepareCardSubmission);
     updateSourceMode();
diff --git a/static/card_form_logic.js b/static/card_form_logic.js
new file mode 100644
index 0000000..aaadb16
--- /dev/null
+++ b/static/card_form_logic.js
@@ -0,0 +1,66 @@
+(function exportCardFormLogic(root)
+{
+    /** Return whether a URL is an absolute HTTP or HTTPS image source. */
+    function validRemoteImageUrl(value)
+    {
+        try
+        {
+            const url = new URL(value);
+            return url.protocol == "http:" || url.protocol == "https:";
+        }
+        catch(error)
+        {
+            return false;
+        }
+    }
+
+    /** Return whether a changed resulting foil card needs a thumbnail. */
+    function foilThumbnailRequired(options)
+    {
+        const rendering_changed = options.mode == "create" ||
+            options.front_action == "replace" ||
+            options.foil_action != "keep";
+        let resulting_foil = options.has_current_foil;
+        if(options.foil_action == "remove")
+        {
+            resulting_foil = false;
+        }
+        else if(options.foil_action == "replace")
+        {
+            resulting_foil = options.has_replacement_foil;
+        }
+        return rendering_changed && resulting_foil;
+    }
+
+    /** Return enabled and required image inputs for the current form state. */
+    function sourceInputState(options)
+    {
+        const uses_files = options.source_mode == "files";
+        const needs_front = options.mode == "create" ||
+            options.front_action == "replace";
+        const replaces_foil = options.mode == "create" ||
+            options.foil_action == "replace";
+        return {
+            front_file_enabled: uses_files && needs_front,
+            foil_file_enabled: uses_files && replaces_foil,
+            front_url_enabled: !uses_files && needs_front,
+            foil_url_enabled: !uses_files && replaces_foil,
+            front_required: needs_front,
+            foil_required: options.mode == "edit" && replaces_foil,
+        };
+    }
+
+    const api = {
+        validRemoteImageUrl,
+        foilThumbnailRequired,
+        sourceInputState,
+    };
+    if(typeof module != "undefined" && module.exports != null)
+    {
+        module.exports = api;
+    }
+    else
+    {
+        root.cardFormLogic = api;
+    }
+})(globalThis);
diff --git a/static/css/styles.css b/static/css/styles.css
index 5861370..1323c00 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -833,6 +833,65 @@ h1 {
     font-size: 0.8rem;
 }
 
+.page-shell {
+    width: min(68rem, 100%);
+    margin-inline: auto;
+}
+
+.form-page {
+    width: min(40rem, 100%);
+    padding: clamp(1.5rem, 5vw, 3rem);
+    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);
+}
+
+.standalone-form {
+    display: grid;
+    gap: 1.25rem;
+    margin-top: 2rem;
+}
+
+.empty-panel,
+.series-admin-list > li {
+    padding: 1.5rem;
+    border-radius: 1.75rem;
+    background: rgb(255 255 255 / 70%);
+    box-shadow: var(--clay-card-shadow);
+}
+
+.series-admin-list {
+    display: grid;
+    gap: 1.25rem;
+    margin: 0;
+    padding: 0;
+    list-style: none;
+}
+
+.series-admin-list > li,
+.series-admin-actions {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 1.25rem;
+}
+
+.series-admin-list h2,
+.empty-panel h2 {
+    margin: 0;
+}
+
+.series-admin-actions a {
+    color: var(--violet);
+    font-weight: 800;
+}
+
+.destructive-submit {
+    background: linear-gradient(145deg, #f87171, #be123c);
+}
+
 :focus-visible {
     outline: 0.25rem solid rgb(124 58 237 / 30%);
     outline-offset: 0.2rem;
diff --git a/static/foil/card_preview.js b/static/foil/card_preview.js
index 9982ab8..36bbaf6 100644
--- a/static/foil/card_preview.js
+++ b/static/foil/card_preview.js
@@ -124,66 +124,6 @@ function asymptoticBound(value)
     return Math.atan(value) / Math.PI;
 }
 
-/** Find the inclusive nontransparent bounds in bottom-origin RGBA pixels. */
-function findAlphaBounds(pixels, width, height)
-{
-    let min_x = width;
-    let min_y = height;
-    let max_x = -1;
-    let max_y = -1;
-    for(let y = 0; y < height; ++y)
-    {
-        for(let x = 0; x < width; ++x)
-        {
-            if(pixels[(y * width + x) * 4 + 3] != 0)
-            {
-                min_x = Math.min(min_x, x);
-                min_y = Math.min(min_y, y);
-                max_x = Math.max(max_x, x);
-                max_y = Math.max(max_y, y);
-            }
-        }
-    }
-    return max_x < 0 ? null : {min_x, min_y, max_x, max_y};
-}
-
-/** Copy bounded bottom-origin WebGL pixels into top-origin ImageData. */
-function cropWebGLPixels(pixels, source_width, bounds)
-{
-    const width = bounds.max_x - bounds.min_x + 1;
-    const height = bounds.max_y - bounds.min_y + 1;
-    const image = new ImageData(width, height);
-    for(let output_y = 0; output_y < height; ++output_y)
-    {
-        const source_y = bounds.max_y - output_y;
-        for(let output_x = 0; output_x < width; ++output_x)
-        {
-            const source_x = bounds.min_x + output_x;
-            const source_offset =
-                (source_y * source_width + source_x) * 4;
-            const output_offset = (output_y * width + output_x) * 4;
-            image.data.set(
-                pixels.subarray(source_offset, source_offset + 4),
-                output_offset);
-        }
-    }
-    return image;
-}
-
-/** Scale dimensions proportionally so their long side is exact. */
-function scaleDimensions(width, height, long_side)
-{
-    if(!Number.isInteger(long_side) || long_side < 1)
-    {
-        throw(new Error("The thumbnail size is invalid."));
-    }
-    const scale = long_side / Math.max(width, height);
-    return {
-        width: Math.max(1, Math.round(width * scale)),
-        height: Math.max(1, Math.round(height * scale)),
-    };
-}
-
 /** Resolve one canvas PNG export or reject a failed encoding. */
 function canvasPng(canvas)
 {
@@ -371,22 +311,24 @@ function main()
                     gl.RGBA,
                     gl.UNSIGNED_BYTE,
                     pixels);
-                const bounds = findAlphaBounds(
+                const bounds = window.cardPreviewMath.findAlphaBounds(
                     pixels, canvas.width, canvas.height);
                 if(bounds == null)
                 {
                     throw(new Error(
                         "The card preview is fully transparent."));
                 }
-                const cropped = cropWebGLPixels(
+                const cropped = window.cardPreviewMath.cropWebGLPixels(
                     pixels, canvas.width, bounds);
                 const source_canvas = document.createElement("canvas");
                 source_canvas.width = cropped.width;
                 source_canvas.height = cropped.height;
+                const cropped_image = new ImageData(
+                    cropped.data, cropped.width, cropped.height);
                 source_canvas.getContext("2d").putImageData(
-                    cropped, 0, 0);
+                    cropped_image, 0, 0);
 
-                const scaled = scaleDimensions(
+                const scaled = window.cardPreviewMath.scaleDimensions(
                     cropped.width, cropped.height, long_side);
                 const output_canvas = document.createElement("canvas");
                 output_canvas.width = scaled.width;
diff --git a/static/foil/card_preview_math.js b/static/foil/card_preview_math.js
new file mode 100644
index 0000000..fd02540
--- /dev/null
+++ b/static/foil/card_preview_math.js
@@ -0,0 +1,72 @@
+(function exportCardPreviewMath(root)
+{
+    /** Find inclusive nontransparent bounds in bottom-origin RGBA pixels. */
+    function findAlphaBounds(pixels, width, height)
+    {
+        let min_x = width;
+        let min_y = height;
+        let max_x = -1;
+        let max_y = -1;
+        for(let y = 0; y < height; ++y)
+        {
+            for(let x = 0; x < width; ++x)
+            {
+                if(pixels[(y * width + x) * 4 + 3] != 0)
+                {
+                    min_x = Math.min(min_x, x);
+                    min_y = Math.min(min_y, y);
+                    max_x = Math.max(max_x, x);
+                    max_y = Math.max(max_y, y);
+                }
+            }
+        }
+        return max_x < 0 ? null : {min_x, min_y, max_x, max_y};
+    }
+
+    /** Crop and flip bottom-origin WebGL pixels into top-origin RGBA data. */
+    function cropWebGLPixels(pixels, source_width, bounds)
+    {
+        const width = bounds.max_x - bounds.min_x + 1;
+        const height = bounds.max_y - bounds.min_y + 1;
+        const data = new Uint8ClampedArray(width * height * 4);
+        for(let output_y = 0; output_y < height; ++output_y)
+        {
+            const source_y = bounds.max_y - output_y;
+            for(let output_x = 0; output_x < width; ++output_x)
+            {
+                const source_x = bounds.min_x + output_x;
+                const source_offset =
+                    (source_y * source_width + source_x) * 4;
+                const output_offset = (output_y * width + output_x) * 4;
+                data.set(
+                    pixels.subarray(source_offset, source_offset + 4),
+                    output_offset);
+            }
+        }
+        return {width, height, data};
+    }
+
+    /** Scale dimensions proportionally so their long side is exact. */
+    function scaleDimensions(width, height, long_side)
+    {
+        if(!Number.isInteger(long_side) || long_side < 1)
+        {
+            throw(new Error("The thumbnail size is invalid."));
+        }
+        const scale = long_side / Math.max(width, height);
+        return {
+            width: Math.max(1, Math.round(width * scale)),
+            height: Math.max(1, Math.round(height * scale)),
+        };
+    }
+
+    const api = {findAlphaBounds, cropWebGLPixels, scaleDimensions};
+    if(typeof module != "undefined" && module.exports != null)
+    {
+        module.exports = api;
+    }
+    else
+    {
+        root.cardPreviewMath = api;
+    }
+})(globalThis);
diff --git a/templates/card_delete.html b/templates/card_delete.html
new file mode 100644
index 0000000..582929f
--- /dev/null
+++ b/templates/card_delete.html
@@ -0,0 +1,15 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell form-page" aria-labelledby="DeleteCardHeading">
+    <a class="back-link" href="{{ back_url }}">← Card details</a>
+    <p class="eyebrow">Card administration</p>
+    <h1 id="DeleteCardHeading">Delete {{ name }}?</h1>
+    <p>This permanently removes the card and its published images.</p>
+    <form class="standalone-form" method="post" action="{{ action_url }}">
+        <button class="form-submit destructive-submit" type="submit">
+            Delete card
+        </button>
+    </form>
+</section>
+{% endblock %}
diff --git a/templates/card_form.html b/templates/card_form.html
index f07ffcd..3618fee 100644
--- a/templates/card_form.html
+++ b/templates/card_form.html
@@ -30,6 +30,14 @@
                     <select id="CardGame" name="game"
                             {% if mode == "edit" %}disabled{% endif %}>
                         <option value="">Loose card</option>
+                        {% for item in games %}
+                        <option value="{{ item.short_name }}"
+                                {% if item.short_name == selected_game %}
+                                selected
+                                {% endif %}>
+                            {{ item.display_name }}
+                        </option>
+                        {% endfor %}
                     </select>
                 </label>
                 <label class="form-field" for="CardName">
@@ -45,6 +53,46 @@
                 </label>
             </section>
 
+            {% for game in games %}
+            <fieldset class="form-section game-fields"
+                      data-game="{{ game.short_name }}"
+                      {% if game.short_name != selected_game %}hidden{% endif %}>
+                <legend>{{ game.display_name }} details</legend>
+                {% for field in game.fields %}
+                <label class="form-field">
+                    <span>{{ field.label }}</span>
+                    <input name="game.{{ field.name }}"
+                           type="{{ field.input_type }}"
+                           {% if length(field.minimum) > 0 %}
+                           min="{{ field.minimum }}"
+                           {% endif %}
+                           {% if field.required %}required{% endif %}
+                           value="{{ field.value }}"
+                           {% if game.short_name != selected_game %}
+                           disabled
+                           {% endif %}>
+                </label>
+                {% endfor %}
+            </fieldset>
+            {% endfor %}
+
+            {% if length(series) > 0 %}
+            <fieldset class="form-section">
+                <legend>Series</legend>
+                {% for item in series %}
+                <label class="form-field series-choice"
+                       data-game="{{ item.game }}"
+                       {% if item.game != selected_game %}hidden{% endif %}>
+                    <input name="series_id" type="checkbox"
+                           value="{{ item.id }}"
+                           {% if item.selected %}checked{% endif %}
+                           {% if item.game != selected_game %}disabled{% endif %}>
+                    <span>{{ item.name }}</span>
+                </label>
+                {% endfor %}
+            </fieldset>
+            {% endif %}
+
             <fieldset class="form-section image-source-section">
                 <legend>Card images</legend>
                 {% if mode == "edit" %}
@@ -174,6 +222,8 @@
 <script src="{{ url_for("static", "foil/gl-matrix-min.js") }}"></script>
 <script src="{{ url_for("static", "foil/libwebgl.js") }}"></script>
 <script src="{{ url_for("static", "foil/obj.js") }}"></script>
+<script src="{{ url_for("static", "foil/card_preview_math.js") }}"></script>
 <script src="{{ preview_script_url }}"></script>
+<script src="{{ url_for("static", "card_form_logic.js") }}"></script>
 <script src="{{ url_for("static", "card_form.js") }}"></script>
 {% endblock %}
diff --git a/templates/card_view.html b/templates/card_view.html
index 64904a5..32889d2 100644
--- a/templates/card_view.html
+++ b/templates/card_view.html
@@ -17,6 +17,7 @@
         <nav class="card-actions" aria-label="Card actions">
             <a class="back-link" href="{{ back_url }}">← All cards</a>
             <a class="back-link" href="{{ edit_url }}">Edit card</a>
+            <a class="back-link" href="{{ delete_url }}">Delete card</a>
         </nav>
         <p class="card-view-id">{{ display_id }}</p>
         <h1 id="CardHeading">{{ name }}</h1>
@@ -46,6 +47,12 @@
                 <dt>Finish</dt>
                 <dd>{% if has_foil %}Foil{% else %}Standard{% endif %}</dd>
             </div>
+            {% for field in game_fields %}
+            <div>
+                <dt>{{ field.label }}</dt>
+                <dd>{{ field.value }}</dd>
+            </div>
+            {% endfor %}
         </dl>
 
         <section class="card-info-section" aria-labelledby="SeriesHeading">
@@ -64,7 +71,7 @@
         {% if has_short_description %}
         <section class="card-info-section" aria-labelledby="SummaryHeading">
             <h2 id="SummaryHeading">Summary</h2>
-            <p class="description-copy">{{ short_description }}</p>
+            <div class="description-copy">{{ short_description }}</div>
         </section>
         {% endif %}
 
@@ -72,7 +79,7 @@
         <section class="card-info-section"
                  aria-labelledby="DescriptionHeading">
             <h2 id="DescriptionHeading">Description</h2>
-            <p class="description-copy">{{ long_description }}</p>
+            <div class="description-copy">{{ long_description }}</div>
         </section>
         {% endif %}
     </aside>
@@ -99,5 +106,6 @@
 <script src="{{ url_for("static", "foil/gl-matrix-min.js") }}"></script>
 <script src="{{ url_for("static", "foil/libwebgl.js") }}"></script>
 <script src="{{ url_for("static", "foil/obj.js") }}"></script>
+<script src="{{ url_for("static", "foil/card_preview_math.js") }}"></script>
 <script src="{{ preview_script_url }}"></script>
 {% endblock %}
diff --git a/templates/series_delete.html b/templates/series_delete.html
new file mode 100644
index 0000000..f4b025b
--- /dev/null
+++ b/templates/series_delete.html
@@ -0,0 +1,15 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell form-page" aria-labelledby="DeleteSeriesHeading">
+    <a class="back-link" href="{{ back_url }}">← All series</a>
+    <p class="eyebrow">Series administration</p>
+    <h1 id="DeleteSeriesHeading">Delete {{ name }}?</h1>
+    <p>Cards in this series will remain in the collection.</p>
+    <form class="standalone-form" method="post" action="{{ action_url }}">
+        <button class="form-submit destructive-submit" type="submit">
+            Delete series
+        </button>
+    </form>
+</section>
+{% endblock %}
diff --git a/templates/series_form.html b/templates/series_form.html
new file mode 100644
index 0000000..2fc31de
--- /dev/null
+++ b/templates/series_form.html
@@ -0,0 +1,38 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell form-page" aria-labelledby="SeriesFormHeading">
+    <a class="back-link" href="{{ back_url }}">← All series</a>
+    <p class="eyebrow">Series administration</p>
+    <h1 id="SeriesFormHeading">{{ heading }}</h1>
+
+    <form class="standalone-form" method="post" action="{{ action_url }}">
+        <label class="form-field" for="SeriesGame">
+            <span>Game</span>
+            {% if mode == "create" %}
+            <select id="SeriesGame" name="game" required>
+                <option value="">Select a game</option>
+                {% for item in games %}
+                <option value="{{ item.short_name }}">
+                    {{ item.display_name }}
+                </option>
+                {% endfor %}
+            </select>
+            {% else %}
+            <input id="SeriesGame" type="text" value="{{ game }}" disabled>
+            {% endif %}
+        </label>
+        <label class="form-field" for="SeriesName">
+            <span>Name</span>
+            <input id="SeriesName" name="name" type="text"
+                   maxlength="200" value="{{ name }}" required>
+        </label>
+        <label class="form-field" for="SeriesDescription">
+            <span>Description</span>
+            <textarea id="SeriesDescription" name="description"
+                      rows="8">{{ description }}</textarea>
+        </label>
+        <button class="form-submit" type="submit">{{ submit_label }}</button>
+    </form>
+</section>
+{% endblock %}
diff --git a/templates/series_index.html b/templates/series_index.html
new file mode 100644
index 0000000..6b94380
--- /dev/null
+++ b/templates/series_index.html
@@ -0,0 +1,46 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell series-page" aria-labelledby="SeriesHeading">
+    <header class="page-heading">
+        <div>
+            <p class="eyebrow">Administration</p>
+            <h1 id="SeriesHeading">Series</h1>
+        </div>
+        {% if can_create %}
+        <a class="nav-link nav-link-primary" href="{{ create_url }}">
+            Create series
+        </a>
+        {% endif %}
+    </header>
+
+    {% if length(series) == 0 %}
+    <div class="empty-panel">
+        <h2>No series yet</h2>
+        {% 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>
+        {% endif %}
+    </div>
+    {% else %}
+    <ul class="series-admin-list">
+        {% for item in series %}
+        <li>
+            <div>
+                <p class="eyebrow">{{ item.game }}</p>
+                <h2>{{ item.name }}</h2>
+                {% if item.has_description %}
+                <div class="description-copy">{{ item.description }}</div>
+                {% endif %}
+            </div>
+            <div class="series-admin-actions">
+                <a href="{{ item.edit_url }}">Edit</a>
+                <a href="{{ item.delete_url }}">Delete</a>
+            </div>
+        </li>
+        {% endfor %}
+    </ul>
+    {% endif %}
+</section>
+{% endblock %}
diff --git a/tests/app_integration_test.cpp b/tests/app_integration_test.cpp
new file mode 100644
index 0000000..86bd9b6
--- /dev/null
+++ b/tests/app_integration_test.cpp
@@ -0,0 +1,247 @@
+#include "app.h"
+
+#include <chrono>
+#include <filesystem>
+#include <fstream>
+#include <iterator>
+#include <memory>
+#include <string>
+
+#include <Magick++.h>
+#include <gtest/gtest.h>
+#include <httplib.h>
+
+#include "game_registry.h"
+#include "non_secret_random.h"
+#include "startup.h"
+#include "test_game.h"
+
+namespace
+{
+
+std::string readFile(const std::filesystem::path& path)
+{
+    std::ifstream input(path, std::ios::binary);
+    return std::string(
+        std::istreambuf_iterator<char>(input),
+        std::istreambuf_iterator<char>());
+}
+
+class TemporaryServerRoot
+{
+public:
+    /// Allocate private database and card-storage paths.
+    TemporaryServerRoot()
+            : path_(
+                  std::filesystem::path(testing::TempDir()) /
+                  ("card_server_" + std::to_string(
+                      std::chrono::steady_clock::now()
+                          .time_since_epoch().count())))
+    {
+        std::filesystem::create_directories(path_ / "cards/published");
+    }
+
+    /// Remove all temporary server state.
+    ~TemporaryServerRoot()
+    {
+        std::error_code error;
+        std::filesystem::remove_all(path_, error);
+    }
+
+    /// Return the temporary root path.
+    const std::filesystem::path& path() const
+    {
+        return path_;
+    }
+
+private:
+    std::filesystem::path path_;
+};
+
+} // namespace
+
+/// Verify real HTTP routing, base paths, and independent static mounts.
+TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
+{
+    Magick::InitializeMagick(nullptr);
+    Magick::ResourceLimits::listLength(2);
+    Magick::ResourceLimits::width(2048);
+    Magick::ResourceLimits::height(2048);
+    TemporaryServerRoot temporary;
+    const int port = 39000 + static_cast<int>(
+        std::chrono::steady_clock::now().time_since_epoch().count() % 1000);
+    auto base_url = mw::URL::fromStr(
+        "http://127.0.0.1:" + std::to_string(port) + "/collection/");
+    ASSERT_TRUE(base_url);
+    Config config = {
+        std::move(*base_url),
+        mw::IPSocketInfo{"127.0.0.1", port},
+        std::filesystem::path(CARD_COLLECTION_SOURCE_DIR) / "static",
+        temporary.path() / "cards.sqlite3",
+        temporary.path() / "cards",
+        75,
+        256,
+    };
+    auto games = std::make_unique<GameRegistry>();
+    ASSERT_TRUE(games->add(std::make_unique<TestGame>()));
+    auto data_source = prepareDataSource(config.database_path, *games);
+    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);
+    auto series_id = (*transaction)->insertSeries(
+        {0, "test", "Core <Set>", "A **series**."});
+    ASSERT_TRUE(series_id);
+    auto number = (*transaction)->allocateGameNumber("test");
+    ASSERT_TRUE(number);
+    auto card_id = (*transaction)->insertCard(
+        {
+            0,
+            {"test", *number},
+            "Card <One>",
+            std::nullopt,
+            std::nullopt,
+            0,
+            "avif",
+            std::nullopt,
+            "avif",
+            1,
+        },
+        game,
+        metadata->get(),
+        {*series_id});
+    ASSERT_TRUE(card_id);
+    ASSERT_TRUE((*transaction)->commit());
+    const std::filesystem::path card_directory =
+        config.card_storage_root / "published/test-1";
+    std::filesystem::create_directories(card_directory);
+    std::ofstream(card_directory / "front-art.avif") << "front";
+    std::ofstream(card_directory / "thumb.avif") << "thumbnail";
+    const std::filesystem::path upload_path =
+        temporary.path() / "upload.png";
+    Magick::Image upload(
+        Magick::Geometry(350, 490), Magick::Color("navy"));
+    upload.write("PNG:" + upload_path.string());
+    const std::string upload_bytes = readFile(upload_path);
+    App app(
+        config,
+        std::move(*data_source),
+        std::move(games),
+        std::make_unique<NonSecretRandom>(1));
+    ASSERT_TRUE(app.start());
+
+    httplib::Client client("127.0.0.1", port);
+    auto index = client.Get("/collection/");
+    ASSERT_NE(index, nullptr);
+    EXPECT_EQ(index->status, 200);
+    EXPECT_NE(index->body.find("Card Collection"), std::string::npos);
+    auto create = client.Get("/collection/cards/new");
+    ASSERT_NE(create, nullptr);
+    EXPECT_EQ(create->status, 200);
+    auto card = client.Get("/collection/cards/test-1");
+    ASSERT_NE(card, nullptr);
+    EXPECT_EQ(card->status, 200);
+    EXPECT_NE(card->body.find("Card &lt;One&gt;"), std::string::npos);
+    auto edit = client.Get("/collection/cards/test-1/edit");
+    ASSERT_NE(edit, nullptr);
+    EXPECT_EQ(edit->status, 200);
+    EXPECT_NE(edit->body.find("value=\"20\""), std::string::npos);
+    auto card_delete = client.Get("/collection/cards/test-1/delete");
+    ASSERT_NE(card_delete, nullptr);
+    EXPECT_EQ(card_delete->status, 200);
+    auto series = client.Get("/collection/series");
+    ASSERT_NE(series, nullptr);
+    EXPECT_EQ(series->status, 200);
+    EXPECT_NE(series->body.find("Core &lt;Set&gt;"), std::string::npos);
+    auto series_new = client.Get("/collection/series/new");
+    ASSERT_NE(series_new, nullptr);
+    EXPECT_EQ(series_new->status, 200);
+    auto series_edit = client.Get(
+        "/collection/series/" + std::to_string(*series_id) + "/edit");
+    ASSERT_NE(series_edit, nullptr);
+    EXPECT_EQ(series_edit->status, 200);
+    auto series_delete = client.Get(
+        "/collection/series/" + std::to_string(*series_id) + "/delete");
+    ASSERT_NE(series_delete, nullptr);
+    EXPECT_EQ(series_delete->status, 200);
+    auto stylesheet = client.Get("/collection/static/css/styles.css");
+    ASSERT_NE(stylesheet, nullptr);
+    EXPECT_EQ(stylesheet->status, 200);
+    auto card_asset = client.Get(
+        "/collection/static-cards/test-1/front-art.avif");
+    ASSERT_NE(card_asset, nullptr);
+    EXPECT_EQ(card_asset->status, 200);
+    EXPECT_EQ(card_asset->body, "front");
+    auto traversal = client.Get(
+        "/collection/static-cards/../cards.sqlite3");
+    ASSERT_NE(traversal, nullptr);
+    EXPECT_EQ(traversal->status, 404);
+
+    const httplib::UploadFormDataItems create_fields = {
+        {"game", "", "", ""},
+        {"name", "Uploaded card", "", ""},
+        {"rarity", "3", "", ""},
+        {"source_mode", "files", "", ""},
+        {"front", upload_bytes, "card.png", "image/png"},
+    };
+    auto created = client.Post("/collection/cards", create_fields);
+    ASSERT_NE(created, nullptr);
+    ASSERT_EQ(created->status, 303) << created->body;
+    const std::string location = created->get_header_value("Location");
+    const std::size_t path_begin = location.find("/collection/cards/");
+    ASSERT_NE(path_begin, std::string::npos);
+    const std::string created_path = location.substr(path_begin);
+    const std::string public_id = created_path.substr(
+        std::string("/collection/cards/").size());
+    EXPECT_TRUE(std::filesystem::is_directory(
+        config.card_storage_root / "published" / public_id));
+
+    const httplib::UploadFormDataItems edit_fields = {
+        {"name", "Edited upload", "", ""},
+        {"rarity", "4", "", ""},
+        {"revision", "1", "", ""},
+        {"source_mode", "files", "", ""},
+        {"front_action", "keep", "", ""},
+        {"foil_action", "keep", "", ""},
+    };
+    auto edited = client.Post(created_path, edit_fields);
+    ASSERT_NE(edited, nullptr);
+    EXPECT_EQ(edited->status, 303) << edited->body;
+    auto edited_page = client.Get(created_path);
+    ASSERT_NE(edited_page, nullptr);
+    EXPECT_NE(edited_page->body.find("Edited upload"), std::string::npos);
+
+    auto deleted = client.Post(created_path + "/delete", "", "text/plain");
+    ASSERT_NE(deleted, nullptr);
+    EXPECT_EQ(deleted->status, 303) << deleted->body;
+    EXPECT_FALSE(std::filesystem::exists(
+        config.card_storage_root / "published" / public_id));
+
+    httplib::Params create_series_fields = {
+        {"game", "test"},
+        {"name", "Uploaded series"},
+        {"description", "Description"},
+    };
+    auto created_series = client.Post(
+        "/collection/series", create_series_fields);
+    ASSERT_NE(created_series, nullptr);
+    EXPECT_EQ(created_series->status, 303) << created_series->body;
+    httplib::Params edit_series_fields = {
+        {"name", "Edited series"},
+        {"description", "Changed"},
+    };
+    auto edited_series = client.Post(
+        "/collection/series/2", edit_series_fields);
+    ASSERT_NE(edited_series, nullptr);
+    EXPECT_EQ(edited_series->status, 303) << edited_series->body;
+    auto deleted_series = client.Post(
+        "/collection/series/2/delete", "", "text/plain");
+    ASSERT_NE(deleted_series, nullptr);
+    EXPECT_EQ(deleted_series->status, 303) << deleted_series->body;
+
+    app.stop();
+    app.wait();
+}
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index 034f9e7..6f6be51 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -14,6 +14,7 @@
 
 #include "data_fake.h"
 #include "public_id.h"
+#include "test_game.h"
 
 namespace
 {
@@ -42,6 +43,26 @@ std::unique_ptr<DataSourceFake> emptyDataSource()
     return std::make_unique<DataSourceFake>();
 }
 
+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 {
@@ -65,7 +86,9 @@ TEST(AppTest, BuildsNamedUrls)
 {
     const App app(
         makeConfig("https://example.test/collection/"),
-        emptyDataSource());
+        emptyDataSource(),
+        emptyGames(),
+        deterministicRandom());
 
     EXPECT_EQ(
         app.urlFor("card-index"),
@@ -86,7 +109,9 @@ TEST(AppTest, BuildsStaticAndQueryUrls)
 {
     const App app(
         makeConfig("https://example.test/collection/"),
-        emptyDataSource());
+        emptyDataSource(),
+        emptyGames(),
+        deterministicRandom());
 
     EXPECT_EQ(
         app.urlFor(
@@ -102,7 +127,9 @@ TEST(AppTest, RejectsInvalidRoutes)
 {
     const App app(
         makeConfig("https://example.test/"),
-        emptyDataSource());
+        emptyDataSource(),
+        emptyGames(),
+        deterministicRandom());
 
     EXPECT_THROW(app.urlFor("missing"), std::invalid_argument);
     EXPECT_THROW(app.urlFor("card"), std::invalid_argument);
@@ -120,7 +147,9 @@ TEST(AppTest, RendersCardIndex)
         });
     App app(
         makeConfig("https://example.test/collection/"),
-        std::move(data_source));
+        std::move(data_source),
+        emptyGames(),
+        deterministicRandom());
     App::Request request;
     App::Response response;
 
@@ -147,7 +176,9 @@ TEST(AppTest, RendersCardCreationForm)
 {
     App app(
         makeConfig("https://example.test/collection/"),
-        emptyDataSource());
+        emptyDataSource(),
+        emptyGames(),
+        deterministicRandom());
     App::Request request;
     App::Response response;
 
@@ -179,7 +210,9 @@ TEST(AppTest, RendersCardEditForm)
         std::vector<Card>{card});
     App app(
         makeConfig("https://example.test/collection/"),
-        std::move(data_source));
+        std::move(data_source),
+        emptyGames(),
+        deterministicRandom());
     auto public_id = formatPublicId(card.identity);
     ASSERT_TRUE(public_id);
     App::Request request;
@@ -206,29 +239,32 @@ TEST(AppTest, RendersCardEditForm)
 TEST(AppTest, RendersCardView)
 {
     Card card = makeCard(2, 2, "<script>Moon card</script>");
+    card.identity.game_short_name = "test";
     card.short_description = "A quiet <night>.";
     card.long_description = "First line\nSecond line";
     card.rarity = 4;
     auto data_source = std::make_unique<DataSourceFake>(
         std::vector<Card>{card},
         std::vector<Series>{
-            {7, "pkm", "Night Signals", "A series description"},
+            {7, "test", "Night Signals", "A series description"},
         },
         std::unordered_map<std::int64_t, std::vector<std::int64_t>>{
             {2, {7}},
         });
     App app(
         makeConfig("https://example.test/collection/"),
-        std::move(data_source));
+        std::move(data_source),
+        testGames(),
+        deterministicRandom());
     App::Request request;
-    request.path_params.emplace("id", "pkm-2");
+    request.path_params.emplace("id", "test-2");
     App::Response response;
 
     app.handleCardView(request, response);
 
     EXPECT_EQ(response.status, 200);
     EXPECT_NE(response.body.find("id=\"GLCanvas\""), std::string::npos);
-    EXPECT_NE(response.body.find("PKM-2"), std::string::npos);
+    EXPECT_NE(response.body.find("TEST-2"), std::string::npos);
     EXPECT_NE(response.body.find("Night Signals"), std::string::npos);
     EXPECT_NE(response.body.find("Some card assets are missing"),
               std::string::npos);
@@ -244,7 +280,9 @@ TEST(AppTest, RejectsUnknownCardView)
 {
     App app(
         makeConfig("https://example.test/"),
-        emptyDataSource());
+        emptyDataSource(),
+        emptyGames(),
+        deterministicRandom());
     App::Request request;
     request.path_params.emplace("id", "PKM-2");
     App::Response response;
diff --git a/tests/card_form_logic_test.js b/tests/card_form_logic_test.js
new file mode 100644
index 0000000..8fd54ca
--- /dev/null
+++ b/tests/card_form_logic_test.js
@@ -0,0 +1,68 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const logic = require("../static/card_form_logic.js");
+
+test("remote images require absolute HTTP or HTTPS URLs", function()
+{
+    assert.equal(logic.validRemoteImageUrl("https://example.test/card.png"),
+                 true);
+    assert.equal(logic.validRemoteImageUrl("http://example.test/card.png"),
+                 true);
+    assert.equal(logic.validRemoteImageUrl("javascript:alert(1)"), false);
+    assert.equal(logic.validRemoteImageUrl("/card.png"), false);
+});
+
+test("thumbnail decisions use actions and resulting finish", function()
+{
+    assert.equal(logic.foilThumbnailRequired({
+        mode: "edit",
+        front_action: "keep",
+        foil_action: "keep",
+        has_current_foil: true,
+        has_replacement_foil: false,
+    }), false);
+    assert.equal(logic.foilThumbnailRequired({
+        mode: "edit",
+        front_action: "replace",
+        foil_action: "keep",
+        has_current_foil: true,
+        has_replacement_foil: false,
+    }), true);
+    assert.equal(logic.foilThumbnailRequired({
+        mode: "edit",
+        front_action: "keep",
+        foil_action: "remove",
+        has_current_foil: true,
+        has_replacement_foil: false,
+    }), false);
+});
+
+test("source input state follows mode and replacements", function()
+{
+    assert.deepEqual(logic.sourceInputState({
+        mode: "create",
+        source_mode: "files",
+        front_action: "replace",
+        foil_action: "replace",
+    }), {
+        front_file_enabled: true,
+        foil_file_enabled: true,
+        front_url_enabled: false,
+        foil_url_enabled: false,
+        front_required: true,
+        foil_required: false,
+    });
+    assert.deepEqual(logic.sourceInputState({
+        mode: "edit",
+        source_mode: "urls",
+        front_action: "keep",
+        foil_action: "replace",
+    }), {
+        front_file_enabled: false,
+        foil_file_enabled: false,
+        front_url_enabled: false,
+        foil_url_enabled: true,
+        front_required: false,
+        foil_required: true,
+    });
+});
diff --git a/tests/card_preview_math_test.js b/tests/card_preview_math_test.js
new file mode 100644
index 0000000..ebf33fb
--- /dev/null
+++ b/tests/card_preview_math_test.js
@@ -0,0 +1,49 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+
+const preview_math = require("../static/foil/card_preview_math.js");
+
+test("scaleDimensions preserves aspect ratio", function()
+{
+    assert.deepEqual(
+        preview_math.scaleDimensions(5, 7, 256),
+        {width: 183, height: 256});
+    assert.deepEqual(
+        preview_math.scaleDimensions(8, 4, 100),
+        {width: 100, height: 50});
+});
+
+test("findAlphaBounds finds nontransparent pixels", function()
+{
+    const pixels = new Uint8Array(3 * 2 * 4);
+    pixels[(0 * 3 + 1) * 4 + 3] = 255;
+    pixels[(1 * 3 + 2) * 4 + 3] = 1;
+
+    assert.deepEqual(
+        preview_math.findAlphaBounds(pixels, 3, 2),
+        {min_x: 1, min_y: 0, max_x: 2, max_y: 1});
+    assert.equal(
+        preview_math.findAlphaBounds(new Uint8Array(8), 2, 1),
+        null);
+});
+
+test("cropWebGLPixels crops and flips rows", function()
+{
+    const pixels = new Uint8Array([
+        1, 0, 0, 255, 2, 0, 0, 255,
+        3, 0, 0, 255, 4, 0, 0, 255,
+    ]);
+    const cropped = preview_math.cropWebGLPixels(
+        pixels,
+        2,
+        {min_x: 0, min_y: 0, max_x: 1, max_y: 1});
+
+    assert.equal(cropped.width, 2);
+    assert.equal(cropped.height, 2);
+    assert.deepEqual(
+        Array.from(cropped.data),
+        [
+            3, 0, 0, 255, 4, 0, 0, 255,
+            1, 0, 0, 255, 2, 0, 0, 255,
+        ]);
+});
diff --git a/tests/card_service_test.cpp b/tests/card_service_test.cpp
index 1b1c943..bb65c49 100644
--- a/tests/card_service_test.cpp
+++ b/tests/card_service_test.cpp
@@ -3,6 +3,7 @@
 #include "game_registry.h"
 #include "multipart_reader.h"
 #include "startup.h"
+#include "test_game.h"
 
 #include <chrono>
 #include <filesystem>
@@ -100,8 +101,10 @@ TEST(CardServiceTest, CreatesLooseCard)
     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 public_id = service.createLooseCard({
@@ -154,8 +157,10 @@ TEST(CardServiceTest, RejectsInvalidImage)
         std::ofstream output(front, std::ios::binary);
         output << "not an image";
     }
+    NonSecretRandom random(1);
     CardService service(
         **data_source,
+        random,
         ImageProcessor(75, 256),
         AssetStore(temporary.path()));
     auto public_id = service.createLooseCard({
@@ -195,8 +200,10 @@ TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
     writeJpeg(foil);
     writePng(thumbnail);
 
+    NonSecretRandom random(1);
     CardService service(
         **data_source,
+        random,
         ImageProcessor(75, 256),
         AssetStore(temporary.path()));
     auto public_id = service.createLooseCard({
@@ -220,6 +227,102 @@ 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)
+{
+    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 = prepareDataSource(database, games);
+    ASSERT_TRUE(data_source);
+
+    auto series_transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(series_transaction);
+    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";
+    writePng(front);
+    NonSecretRandom random(1);
+    CardService service(
+        **data_source,
+        random,
+        ImageProcessor(75, 256),
+        AssetStore(temporary.path()));
+    auto public_id = service.createGameCard(
+        {
+            "Game card",
+            std::nullopt,
+            std::nullopt,
+            2,
+            staging,
+            front,
+            std::nullopt,
+            std::nullopt,
+        },
+        *game,
+        **metadata,
+        {*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);
+    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);
+    const std::filesystem::path edit_staging =
+        temporary.path() / ".staging/edit";
+    std::filesystem::create_directory(edit_staging);
+    auto updated = service.updateGameCard(
+        {
+            **stored,
+            1,
+            "Updated game card",
+            std::nullopt,
+            std::nullopt,
+            3,
+            edit_staging,
+            FrontAssetAction::KEEP,
+            FoilAssetAction::KEEP,
+            std::nullopt,
+            std::nullopt,
+            std::nullopt,
+        },
+        *game,
+        **metadata,
+        {});
+    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");
+    memberships = (*data_source)->getCardSeries((**stored).id);
+    ASSERT_TRUE(memberships);
+    EXPECT_TRUE(memberships->empty());
+}
+
 /// Verify metadata and artwork edits increment revisions and replace assets.
 TEST(CardServiceTest, UpdatesLooseCard)
 {
@@ -235,8 +338,10 @@ TEST(CardServiceTest, UpdatesLooseCard)
     const std::filesystem::path original_front =
         create_staging / "upload_front";
     writePng(original_front);
+    NonSecretRandom random(1);
     CardService service(
         **data_source,
+        random,
         ImageProcessor(75, 256),
         AssetStore(temporary.path()));
     auto public_id = service.createLooseCard({
@@ -339,6 +444,73 @@ TEST(CardServiceTest, UpdatesLooseCard)
     EXPECT_FALSE(
         std::filesystem::exists(published / "front-art.avif"));
     EXPECT_TRUE(std::filesystem::is_regular_file(published / "thumb.avif"));
+
+    auto deleted = service.deleteCard(cards->front());
+    ASSERT_TRUE(deleted) << deleted.error().msg();
+    cards = (*data_source)->getCards();
+    ASSERT_TRUE(cards);
+    EXPECT_TRUE(cards->empty());
+    EXPECT_FALSE(std::filesystem::exists(published));
+}
+
+/// Verify startup reconciliation restores edits/deletes and removes orphans.
+TEST(AssetStoreTest, ReconcilesInterruptedTransitions)
+{
+    initializeImageMagick();
+    TemporaryCardRoot temporary;
+    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
+    GameRegistry games;
+    auto data_source = prepareDataSource(database, games);
+    ASSERT_TRUE(data_source);
+    const std::filesystem::path staging =
+        temporary.path() / ".staging/upload";
+    const std::filesystem::path front = staging / "upload_front";
+    writePng(front);
+    NonSecretRandom random(1);
+    AssetStore assets(temporary.path());
+    CardService service(
+        **data_source,
+        random,
+        ImageProcessor(75, 256),
+        assets);
+    auto public_id = service.createLooseCard({
+        "Recovery card",
+        std::nullopt,
+        std::nullopt,
+        0,
+        staging,
+        front,
+        std::nullopt,
+        std::nullopt,
+    });
+    ASSERT_TRUE(public_id);
+    const std::filesystem::path published =
+        temporary.path() / "published" / *public_id;
+
+    const std::filesystem::path replacement =
+        temporary.path() / ".staging/replacement";
+    std::filesystem::create_directory(replacement);
+    writeJpeg(replacement / "front-art.jpg");
+    writeJpeg(replacement / "thumb.jpg");
+    auto replaced = assets.replace(replacement, *public_id, 1);
+    ASSERT_TRUE(replaced);
+    ASSERT_TRUE(assets.reconcile(**data_source));
+    EXPECT_TRUE(
+        std::filesystem::is_regular_file(published / "front-art.avif"));
+    EXPECT_FALSE(
+        std::filesystem::is_regular_file(published / "front-art.jpg"));
+
+    auto trashed = assets.trash(*public_id, 1, "abc123");
+    ASSERT_TRUE(trashed);
+    EXPECT_FALSE(std::filesystem::exists(published));
+    ASSERT_TRUE(assets.reconcile(**data_source));
+    EXPECT_TRUE(std::filesystem::is_directory(published));
+
+    const std::filesystem::path orphan =
+        temporary.path() / "published/1";
+    std::filesystem::create_directory(orphan);
+    ASSERT_TRUE(assets.reconcile(**data_source));
+    EXPECT_FALSE(std::filesystem::exists(orphan));
 }
 
 /// Verify multipart binaries use server paths and empty file controls vanish.
diff --git a/tests/config_test.cpp b/tests/config_test.cpp
new file mode 100644
index 0000000..2dec367
--- /dev/null
+++ b/tests/config_test.cpp
@@ -0,0 +1,145 @@
+#include "config.h"
+#include "non_secret_random.h"
+
+#include <chrono>
+#include <filesystem>
+#include <fstream>
+#include <string>
+#include <variant>
+
+#include <gtest/gtest.h>
+
+namespace
+{
+
+class TemporaryConfigRoot
+{
+public:
+    /// Allocate a temporary configuration directory with static assets.
+    TemporaryConfigRoot()
+            : path_(
+                  std::filesystem::path(testing::TempDir()) /
+                  ("card_config_" + std::to_string(
+                      std::chrono::steady_clock::now()
+                          .time_since_epoch().count())))
+    {
+        std::filesystem::create_directories(path_ / "static");
+    }
+
+    /// Remove the complete temporary configuration directory.
+    ~TemporaryConfigRoot()
+    {
+        std::error_code error;
+        std::filesystem::remove_all(path_, error);
+    }
+
+    /// Return the temporary configuration directory.
+    const std::filesystem::path& path() const
+    {
+        return path_;
+    }
+
+private:
+    std::filesystem::path path_;
+};
+
+void writeConfig(
+    const std::filesystem::path& path,
+    const std::string& listen_fields,
+    const std::string& extra = {})
+{
+    std::ofstream output(path);
+    output
+        << "base_url = \"https://example.test/collection\"\n"
+        << listen_fields
+        << "static_root = \"static\"\n"
+        << "database_path = \"var/cards.sqlite3\"\n"
+        << "card_storage_root = \"var/cards\"\n"
+        << "avif_quality = 75\n"
+        << "thumbnail_long_side = 256\n"
+        << extra;
+}
+
+} // namespace
+
+/// Verify TCP configuration resolves relative paths beside its TOML file.
+TEST(ConfigTest, LoadsTcpConfiguration)
+{
+    TemporaryConfigRoot temporary;
+    const std::filesystem::path config_path = temporary.path() / "app.toml";
+    writeConfig(
+        config_path,
+        "listen_address = \"127.0.0.1\"\nlisten_port = 8080\n");
+
+    auto config = loadConfig(config_path);
+
+    ASSERT_TRUE(config) << config.error().msg();
+    EXPECT_EQ(
+        config->base_url.str(),
+        "https://example.test/collection/");
+    ASSERT_TRUE(std::holds_alternative<mw::IPSocketInfo>(
+        config->listen_address));
+    const mw::IPSocketInfo& address =
+        std::get<mw::IPSocketInfo>(config->listen_address);
+    EXPECT_EQ(address.address, "127.0.0.1");
+    EXPECT_EQ(address.port, 8080);
+    EXPECT_EQ(config->static_root, temporary.path() / "static");
+    EXPECT_TRUE(std::filesystem::is_directory(
+        temporary.path() / "var/cards/published"));
+}
+
+/// Verify Unix listeners do not require a TCP port.
+TEST(ConfigTest, LoadsUnixSocketConfiguration)
+{
+    TemporaryConfigRoot temporary;
+    const std::filesystem::path config_path = temporary.path() / "app.toml";
+    writeConfig(
+        config_path,
+        "listen_address = \"unix:/tmp/card-collection.sock\"\n");
+
+    auto config = loadConfig(config_path);
+
+    ASSERT_TRUE(config) << config.error().msg();
+    ASSERT_TRUE(std::holds_alternative<mw::SocketFileInfo>(
+        config->listen_address));
+    EXPECT_EQ(
+        std::get<mw::SocketFileInfo>(config->listen_address).filename,
+        "/tmp/card-collection.sock");
+}
+
+/// Verify unknown configuration keys are rejected.
+TEST(ConfigTest, RejectsUnknownKeys)
+{
+    TemporaryConfigRoot temporary;
+    const std::filesystem::path config_path = temporary.path() / "app.toml";
+    writeConfig(
+        config_path,
+        "listen_address = \"127.0.0.1\"\nlisten_port = 8080\n",
+        "misspelled_setting = true\n");
+
+    EXPECT_FALSE(loadConfig(config_path));
+}
+
+/// Verify equal deterministic seeds produce equal values and byte strings.
+TEST(NonSecretRandomTest, RepeatsDeterministicSequence)
+{
+    NonSecretRandom left(42);
+    NonSecretRandom right(42);
+
+    for(int index = 0; index < 128; ++index)
+    {
+        EXPECT_EQ(left.next(), right.next());
+    }
+    EXPECT_EQ(left.hex(32), right.hex(32));
+}
+
+/// Verify hexadecimal output has the requested lowercase encoded size.
+TEST(NonSecretRandomTest, ProducesLowercaseHex)
+{
+    NonSecretRandom random(7);
+    const std::string value = random.hex(32);
+
+    ASSERT_EQ(value.size(), 64);
+    EXPECT_EQ(value.find_first_not_of("0123456789abcdef"),
+              std::string::npos);
+}
diff --git a/tests/data_mock.h b/tests/data_mock.h
index 3afc0fc..b658f16 100644
--- a/tests/data_mock.h
+++ b/tests/data_mock.h
@@ -140,6 +140,13 @@ public:
         (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>>),
diff --git a/tests/data_sqlite_test.cpp b/tests/data_sqlite_test.cpp
index 56fdde3..5fd3eb8 100644
--- a/tests/data_sqlite_test.cpp
+++ b/tests/data_sqlite_test.cpp
@@ -1,6 +1,7 @@
 #include "data_sqlite.h"
 #include "game_registry.h"
 #include "startup.h"
+#include "test_game.h"
 
 #include <chrono>
 #include <cstdint>
@@ -72,8 +73,8 @@ TEST(DataSourceSQLiteTest, OpensDatabase)
     EXPECT_NE(*data_source, nullptr);
 }
 
-/// Verify unfinished persistence operations report explicit errors.
-TEST(DataSourceSQLiteTest, ReportsPlaceholderOperations)
+/// Verify a fresh unmigrated connection has no application tables.
+TEST(DataSourceSQLiteTest, RejectsReadsBeforeMigration)
 {
     auto data_source_result = DataSourceSQLite::fromFile(":memory:");
     ASSERT_TRUE(data_source_result);
@@ -256,6 +257,128 @@ TEST(DataSourceSQLiteTest, UpdatesLooseCard)
     EXPECT_EQ((**stored).revision, 2);
 }
 
+/// Verify series can be created, edited, listed, and deleted transactionally.
+TEST(DataSourceSQLiteTest, MutatesSeries)
+{
+    TemporaryDatabase database;
+    GameRegistry games;
+    auto data_source = prepareDataSource(database.path(), games);
+    ASSERT_TRUE(data_source);
+
+    auto transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    auto series_id = (*transaction)->insertSeries(
+        {0, "test", "First series", "Description"});
+    ASSERT_TRUE(series_id);
+    ASSERT_TRUE((*transaction)->commit());
+
+    auto all_series = (*data_source)->getSeries();
+    ASSERT_TRUE(all_series);
+    ASSERT_EQ(all_series->size(), 1);
+    EXPECT_EQ(all_series->front().id, *series_id);
+    auto series = (*data_source)->getSeries(*series_id);
+    ASSERT_TRUE(series);
+    ASSERT_TRUE(*series);
+    (**series).name = "Edited series";
+
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->updateSeries(**series));
+    ASSERT_TRUE((*transaction)->commit());
+    series = (*data_source)->getSeries(*series_id);
+    ASSERT_TRUE(series);
+    ASSERT_TRUE(*series);
+    EXPECT_EQ((**series).name, "Edited series");
+
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->deleteSeries(*series_id));
+    ASSERT_TRUE((*transaction)->commit());
+    series = (*data_source)->getSeries(*series_id);
+    ASSERT_TRUE(series);
+    EXPECT_FALSE(*series);
+}
+
+/// Verify compiled-game schema, numbering, metadata, and memberships.
+TEST(DataSourceSQLiteTest, PersistsCompiledGameCards)
+{
+    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);
+    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);
+    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,
+    };
+    auto card_id = (*transaction)->insertCard(
+        card, game, metadata->get(), {*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);
+    Card updated = **stored;
+    updated.revision = 2;
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->updateCard(
+        updated, game, metadata->get(), {}));
+    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");
+}
+
 /// Verify destroying an uncommitted transaction rolls card insertion back.
 TEST(DataSourceSQLiteTest, RollsBackLooseCard)
 {
diff --git a/tests/markdown_renderer_test.cpp b/tests/markdown_renderer_test.cpp
new file mode 100644
index 0000000..e626999
--- /dev/null
+++ b/tests/markdown_renderer_test.cpp
@@ -0,0 +1,37 @@
+#include "markdown_renderer.h"
+
+#include <string>
+
+#include <gtest/gtest.h>
+
+/// Verify ordinary Markdown renders its structural HTML.
+TEST(MarkdownRendererTest, RendersFormatting)
+{
+    MarkdownRenderer renderer;
+    auto html = renderer.render("A **bright** card.");
+
+    ASSERT_TRUE(html) << html.error().msg();
+    EXPECT_NE(html->value().find("<strong>bright</strong>"),
+              std::string::npos);
+}
+
+/// Verify literal user HTML is escaped before MacroDown evaluation.
+TEST(MarkdownRendererTest, EscapesRawHtml)
+{
+    MarkdownRenderer renderer;
+    auto html = renderer.render("<script>alert('x')</script>");
+
+    ASSERT_TRUE(html) << html.error().msg();
+    EXPECT_EQ(html->value().find("<script>"), std::string::npos);
+    EXPECT_NE(html->value().find("&lt;script&gt;"), std::string::npos);
+}
+
+/// Verify unsafe and relative Markdown URLs are rejected.
+TEST(MarkdownRendererTest, RejectsUnsafeUrls)
+{
+    MarkdownRenderer renderer;
+
+    EXPECT_FALSE(renderer.render("[bad](javascript:alert(1))"));
+    EXPECT_FALSE(renderer.render("[relative](/cards/1)"));
+    EXPECT_TRUE(renderer.render("[safe](https://example.test/a?b=c)"));
+}
diff --git a/tests/test_game.h b/tests/test_game.h
new file mode 100644
index 0000000..c1fb7ee
--- /dev/null
+++ b/tests/test_game.h
@@ -0,0 +1,222 @@
+#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)},
+        };
+    }
+};