BareGit

Implement internal game

Author: MetroWind <chris.corsair@gmail.com>
Date: Thu Sep 10 15:24:08 2026 -0700
Commit: a47cce7a0dc243c1802b632a3a1a015721e1a195

Changes

diff --git a/src/app.cpp b/src/app.cpp
index 746ffa0..8dd10ff 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -668,6 +668,25 @@ mw::E<std::int64_t> positiveParameter(
     return *value;
 }
 
+mw::E<GameVisibility> visibilityParameter(const App::Request& request)
+{
+    auto value = scalarParameter(request, "visibility");
+    if(!value)
+    {
+        return std::unexpected(std::move(value.error()));
+    }
+    if(*value == "PUBLIC")
+    {
+        return GameVisibility::PUBLIC;
+    }
+    if(*value == "INTERNAL")
+    {
+        return GameVisibility::INTERNAL;
+    }
+    return std::unexpected(mw::httpError(
+        400, "The form field 'visibility' is invalid"));
+}
+
 std::optional<std::string> pathParameter(
     const App::Request& request, const std::string& name)
 {
@@ -787,9 +806,9 @@ inja::json gameFormJson(
 }
 
 mw::E<std::vector<GameDefinitionSnapshot>> loadGameDefinitions(
-    DataSourceInterface& data_source)
+    DataSourceInterface& data_source, GameContentScope scope)
 {
-    auto games = data_source.getGames();
+    auto games = data_source.getGames(scope);
     if(!games)
     {
         return std::unexpected(std::move(games.error()));
@@ -798,7 +817,8 @@ mw::E<std::vector<GameDefinitionSnapshot>> loadGameDefinitions(
     definitions.reserve(games->size());
     for(const Game& game : *games)
     {
-        auto definition = data_source.getGameDefinition(game.short_name);
+        auto definition = data_source.getGameDefinition(
+            game.short_name, scope);
         if(!definition)
         {
             return std::unexpected(std::move(definition.error()));
@@ -1072,8 +1092,11 @@ void App::respondCardFieldError(
     const RequestIdentity& identity,
     const Card* card)
 {
-    auto definitions = loadGameDefinitions(*data_source_);
-    auto all_series = data_source_->getSeries();
+    AuthorizationService authorization;
+    const GameContentScope scope = authorization.gameContentScope(
+        identity.session.user);
+    auto definitions = loadGameDefinitions(*data_source_, scope);
+    auto all_series = data_source_->getSeries(scope);
     if(!definitions || !all_series)
     {
         respondInternalError(response);
@@ -1220,12 +1243,14 @@ void App::respondGameDefinitionError(
     const std::string& short_name,
     const std::string& display_name,
     const std::string& description,
+    GameVisibility visibility,
     bool editing)
 {
     std::optional<GameDefinitionSnapshot> definition;
     if(editing)
     {
-        auto loaded = data_source_->getGameDefinition(short_name);
+        auto loaded = data_source_->getGameDefinition(
+            short_name, GameContentScope::INCLUDE_INTERNAL);
         if(!loaded)
         {
             respondInternalError(response);
@@ -1308,6 +1333,8 @@ void App::respondGameDefinitionError(
             ? "Edit " + definition->game.display_name
             : "Create game"},
         {"mode", editing ? "edit" : "create"},
+        {"visibility_internal", visibility == GameVisibility::INTERNAL},
+        {"visibility_public", visibility == GameVisibility::PUBLIC},
         {"short_name", renderableGameText(short_name)},
         {"submit_label", editing ? "Save game" : "Create game"},
         {"title", editing
@@ -1331,7 +1358,8 @@ void App::respondGameFieldDefinitionError(
     const std::string& type,
     const std::vector<std::string>& choices)
 {
-    auto loaded = data_source_->getGameDefinition(short_name);
+    auto loaded = data_source_->getGameDefinition(
+        short_name, GameContentScope::INCLUDE_INTERNAL);
     if(!loaded)
     {
         respondInternalError(response);
@@ -1733,7 +1761,10 @@ void App::handleCollection(const Request& request, Response& response)
         return;
     }
     auto user = collection_service_->refresh(identity->session.user.id);
-    auto collection = data_source_->getCollection(identity->session.user.id);
+    AuthorizationService authorization;
+    auto collection = data_source_->getCollection(
+        identity->session.user.id,
+        authorization.gameContentScope(identity->session.user));
     auto pool = data_source_->getPoolCards();
     if(!user || !collection || !pool)
     {
@@ -1865,7 +1896,8 @@ void App::handleAdminGames(const Request& request, Response& response)
         return;
     }
 
-    auto games = data_source_->getGames();
+    auto games = data_source_->getGames(
+        GameContentScope::INCLUDE_INTERNAL);
     if(!games)
     {
         respondInternalError(response);
@@ -1875,7 +1907,8 @@ void App::handleAdminGames(const Request& request, Response& response)
     std::vector<HtmlSubstitution> html_substitutions;
     for(const Game& game : *games)
     {
-        auto definition = data_source_->getGameDefinition(game.short_name);
+        auto definition = data_source_->getGameDefinition(
+            game.short_name, GameContentScope::INCLUDE_INTERNAL);
         if(!definition || !*definition)
         {
             respondInternalError(response);
@@ -1915,6 +1948,8 @@ void App::handleAdminGames(const Request& request, Response& response)
             {"fields", std::move(fields)},
             {"has_description", !current_game.description.empty()},
             {"initial", current_game.short_name.substr(0, 1)},
+            {"internal",
+             current_game.visibility == GameVisibility::INTERNAL},
             {"short_name", current_game.short_name},
         });
     }
@@ -1968,6 +2003,8 @@ void App::handleGameNew(const Request& request, Response& response)
         {"error_message", ""},
         {"has_errors", false},
         {"mode", "create"},
+        {"visibility_internal", false},
+        {"visibility_public", true},
         {"short_name", ""},
         {"submit_label", "Create game"},
         {"title", "Create game ยท Card Collection"},
@@ -1990,16 +2027,18 @@ void App::handleGameCreate(const Request& request, Response& response)
     auto short_name = scalarParameter(request, "short_name");
     auto display_name = scalarParameter(request, "display_name");
     auto description = scalarParameter(request, "description", false);
-    if(!short_name || !display_name || !description)
+    auto visibility = visibilityParameter(request);
+    if(!short_name || !display_name || !description || !visibility)
     {
         const mw::Error& error = !short_name ? short_name.error()
-            : !display_name ? display_name.error() : description.error();
+            : !display_name ? display_name.error()
+            : !description ? description.error() : visibility.error();
         respondOperationError(response, error, "Invalid game form");
         return;
     }
     auto created = game_service_->createGame(
         identity->session.user.id, *short_name,
-        *display_name, *description);
+        *display_name, *description, *visibility);
     if(!created)
     {
         const auto* validation =
@@ -2013,6 +2052,7 @@ void App::handleGameCreate(const Request& request, Response& response)
                 *short_name,
                 *display_name,
                 *description,
+                *visibility,
                 false);
             return;
         }
@@ -2047,7 +2087,8 @@ void App::handleGameEdit(const Request& request, Response& response)
         respondNotFound(response);
         return;
     }
-    auto definition = data_source_->getGameDefinition(*short_name);
+    auto definition = data_source_->getGameDefinition(
+        *short_name, GameContentScope::INCLUDE_INTERNAL);
     if(!definition)
     {
         respondInternalError(response);
@@ -2113,6 +2154,10 @@ void App::handleGameEdit(const Request& request, Response& response)
         {"error_message", ""},
         {"has_errors", false},
         {"mode", "edit"},
+        {"visibility_internal",
+         snapshot.game.visibility == GameVisibility::INTERNAL},
+        {"visibility_public",
+         snapshot.game.visibility == GameVisibility::PUBLIC},
         {"short_name", snapshot.game.short_name},
         {"submit_label", "Save game"},
         {"title", "Edit " + snapshot.game.display_name +
@@ -2137,7 +2182,9 @@ void App::handleGameUpdate(const Request& request, Response& response)
     auto revision = positiveParameter(request, "game_revision");
     auto display_name = scalarParameter(request, "display_name");
     auto description = scalarParameter(request, "description", false);
-    if(!short_name || !revision || !display_name || !description)
+    auto visibility = visibilityParameter(request);
+    if(!short_name || !revision || !display_name || !description ||
+       !visibility)
     {
         if(!short_name)
         {
@@ -2147,14 +2194,15 @@ void App::handleGameUpdate(const Request& request, Response& response)
         {
             const mw::Error& error = !revision ? revision.error()
                 : !display_name ? display_name.error()
-                                : description.error();
+                : !description ? description.error()
+                               : visibility.error();
             respondOperationError(response, error, "Invalid game form");
         }
         return;
     }
     auto updated = game_service_->updateGame(
         identity->session.user.id, *short_name, *revision,
-        *display_name, *description);
+        *display_name, *description, *visibility);
     if(!updated)
     {
         const auto* validation =
@@ -2168,6 +2216,7 @@ void App::handleGameUpdate(const Request& request, Response& response)
                 *short_name,
                 *display_name,
                 *description,
+                *visibility,
                 true);
             return;
         }
@@ -2199,7 +2248,8 @@ void App::handleGameDeleteConfirm(
     }
     auto short_name = pathParameter(request, "short");
     auto definition = short_name
-        ? data_source_->getGameDefinition(*short_name)
+        ? data_source_->getGameDefinition(
+              *short_name, GameContentScope::INCLUDE_INTERNAL)
         : mw::E<std::optional<GameDefinitionSnapshot>>(
             std::optional<GameDefinitionSnapshot>{});
     if(!definition || !*definition)
@@ -2284,7 +2334,8 @@ void App::handleGameFieldNew(const Request& request, Response& response)
     }
     auto short_name = pathParameter(request, "short");
     auto definition = short_name
-        ? data_source_->getGameDefinition(*short_name)
+        ? data_source_->getGameDefinition(
+              *short_name, GameContentScope::INCLUDE_INTERNAL)
         : mw::E<std::optional<GameDefinitionSnapshot>>(
             std::optional<GameDefinitionSnapshot>{});
     if(!definition || !*definition)
@@ -2426,7 +2477,8 @@ void App::handleGameFieldEdit(
         respondNotFound(response);
         return;
     }
-    auto definition = data_source_->getGameDefinition(*short_name);
+    auto definition = data_source_->getGameDefinition(
+        *short_name, GameContentScope::INCLUDE_INTERNAL);
     if(!definition || !*definition)
     {
         if(!definition)
@@ -2571,7 +2623,8 @@ void App::handleGameFieldDeleteConfirm(
         respondNotFound(response);
         return;
     }
-    auto definition = data_source_->getGameDefinition(*short_name);
+    auto definition = data_source_->getGameDefinition(
+        *short_name, GameContentScope::INCLUDE_INTERNAL);
     if(!definition || !*definition)
     {
         if(!definition)
@@ -2754,7 +2807,9 @@ void App::handleCardNew(
         return;
     }
     inja::json games = inja::json::array();
-    auto definitions = loadGameDefinitions(*data_source_);
+    const GameContentScope scope = authorization.gameContentScope(
+        identity->session.user);
+    auto definitions = loadGameDefinitions(*data_source_, scope);
     if(!definitions)
     {
         spdlog::error(
@@ -2768,7 +2823,7 @@ void App::handleCardNew(
         games.push_back(gameFormJson(definition, {}));
     }
     inja::json series = inja::json::array();
-    auto series_result = data_source_->getSeries();
+    auto series_result = data_source_->getSeries(scope);
     if(!series_result)
     {
         spdlog::error(
@@ -2867,7 +2922,10 @@ void App::handleCardEdit(
         respondNotFound(response);
         return;
     }
-    auto card_result = data_source_->getCard(*identity);
+    AuthorizationService authorization;
+    const GameContentScope scope = authorization.gameContentScope(
+        actor->session.user);
+    auto card_result = data_source_->getCard(*identity, scope);
     if(!card_result)
     {
         spdlog::error(
@@ -2883,7 +2941,6 @@ void App::handleCardEdit(
         return;
     }
     const Card& card = **card_result;
-    AuthorizationService authorization;
     if(!authorization.canEditCard(actor->session.user, card))
     {
         respondNotFound(response);
@@ -2915,7 +2972,7 @@ void App::handleCardEdit(
     std::optional<GameDefinitionSnapshot> selected_game;
     if(card.identity.game_short_name)
     {
-        auto values = data_source_->getCardFieldValues(card.id);
+        auto values = data_source_->getCardFieldValues(card.id, scope);
         if(!values)
         {
             spdlog::error(
@@ -2935,7 +2992,7 @@ void App::handleCardEdit(
     }
 
     inja::json games = inja::json::array();
-    auto definitions = loadGameDefinitions(*data_source_);
+    auto definitions = loadGameDefinitions(*data_source_, scope);
     if(!definitions)
     {
         respondInternalError(response);
@@ -2952,7 +3009,7 @@ void App::handleCardEdit(
             selected ? current_game_values : std::vector<GameFieldValue>{}));
     }
 
-    auto all_series = data_source_->getSeries();
+    auto all_series = data_source_->getSeries(scope);
     auto memberships = data_source_->getCardSeries(card.id);
     if(!all_series || !memberships)
     {
@@ -3233,7 +3290,10 @@ void App::handleCardUpdate(
         respondNotFound(response);
         return;
     }
-    auto card_result = data_source_->getCard(*identity);
+    AuthorizationService authorization;
+    const GameContentScope scope = authorization.gameContentScope(
+        actor->session.user);
+    auto card_result = data_source_->getCard(*identity, scope);
     if(!card_result)
     {
         spdlog::error(
@@ -3248,7 +3308,6 @@ void App::handleCardUpdate(
         respondNotFound(response);
         return;
     }
-    AuthorizationService authorization;
     if(!authorization.canEditCard(actor->session.user, **card_result))
     {
         respondNotFound(response);
@@ -3489,7 +3548,10 @@ void App::handleCardView(
         respondNotFound(response);
         return;
     }
-    auto card_result = data_source_->getCard(*identity);
+    AuthorizationService authorization;
+    const GameContentScope scope = authorization.gameContentScope(
+        actor->session.user);
+    auto card_result = data_source_->getCard(*identity, scope);
     if(!card_result)
     {
         spdlog::error(
@@ -3506,7 +3568,6 @@ void App::handleCardView(
     }
     const Card& card = **card_result;
     auto owns = data_source_->userOwnsCard(actor->session.user.id, card.id);
-    AuthorizationService authorization;
     if(!owns)
     {
         respondInternalError(response);
@@ -3580,7 +3641,7 @@ void App::handleCardView(
     std::string game_name = "Loose card";
     if(card.identity.game_short_name)
     {
-        auto fields = data_source_->getCardFieldValues(card.id);
+        auto fields = data_source_->getCardFieldValues(card.id, scope);
         if(!fields)
         {
             spdlog::error(
@@ -3628,7 +3689,7 @@ void App::handleCardView(
     inja::json series = inja::json::array();
     for(std::int64_t series_id : *membership_result)
     {
-        auto series_result = data_source_->getSeries(series_id);
+        auto series_result = data_source_->getSeries(series_id, scope);
         if(!series_result)
         {
             spdlog::error(
@@ -3774,7 +3835,8 @@ void App::handleCardDeleteConfirm(
         respondNotFound(response);
         return;
     }
-    auto card = data_source_->getCard(*identity);
+    auto card = data_source_->getCard(
+        *identity, GameContentScope::INCLUDE_INTERNAL);
     if(!card || !*card)
     {
         if(!card)
@@ -3833,7 +3895,8 @@ void App::handleCardDelete(
         respondNotFound(response);
         return;
     }
-    auto card = data_source_->getCard(*identity);
+    auto card = data_source_->getCard(
+        *identity, GameContentScope::INCLUDE_INTERNAL);
     if(!card || !*card)
     {
         if(!card)
@@ -3902,8 +3965,9 @@ void App::handleCardIndex(
         return;
     }
     auto cards_result = creator_route
-        ? data_source_->getCardsByCreator(actor->session.user.id)
-        : data_source_->getCards();
+        ? data_source_->getCardsByCreator(
+              actor->session.user.id, GameContentScope::PUBLIC_ONLY)
+        : data_source_->getCards(GameContentScope::INCLUDE_INTERNAL);
     if(!cards_result)
     {
         spdlog::error(
@@ -3935,6 +3999,22 @@ void App::handleCardIndex(
         request.get_param_value("direction") == "desc";
     sortIndexCards(cards, descending);
 
+    std::unordered_map<std::string, GameVisibility> game_visibility;
+    if(!creator_route)
+    {
+        auto games = data_source_->getGames(
+            GameContentScope::INCLUDE_INTERNAL);
+        if(!games)
+        {
+            respondInternalError(response);
+            return;
+        }
+        for(const Game& game : *games)
+        {
+            game_visibility.emplace(game.short_name, game.visibility);
+        }
+    }
+
     inja::json template_cards = inja::json::array();
     for(const IndexCard& index_card : cards)
     {
@@ -3970,8 +4050,21 @@ void App::handleCardIndex(
             thumbnail_url = urlFor("static", {"card_placeholder.svg"});
         }
 
+        bool internal = false;
+        if(!creator_route && card.identity.game_short_name)
+        {
+            const auto visibility = game_visibility.find(
+                *card.identity.game_short_name);
+            if(visibility == game_visibility.end())
+            {
+                respondInternalError(response);
+                return;
+            }
+            internal = visibility->second == GameVisibility::INTERNAL;
+        }
         template_cards.push_back({
             {"display_id", uppercaseAscii(index_card.public_id)},
+            {"internal", internal},
             {"name", card.name},
             {"thumbnail_url", std::move(thumbnail_url)},
             {"url", urlFor("card", {index_card.public_id})},
@@ -4026,7 +4119,8 @@ void App::handleSeriesIndex(
         response.status = 403;
         return;
     }
-    auto series_result = data_source_->getSeries();
+    auto series_result = data_source_->getSeries(
+        GameContentScope::INCLUDE_INTERNAL);
     if(!series_result)
     {
         spdlog::error(
@@ -4037,7 +4131,8 @@ void App::handleSeriesIndex(
     }
     inja::json series = inja::json::array();
     std::vector<HtmlSubstitution> html_substitutions;
-    auto games_result = data_source_->getGames();
+    auto games_result = data_source_->getGames(
+        GameContentScope::INCLUDE_INTERNAL);
     if(!games_result)
     {
         respondInternalError(response);
@@ -4074,6 +4169,8 @@ void App::handleSeriesIndex(
             {"game", game == games_result->end()
                  ? uppercaseAscii(item.game_short_name)
                  : game->display_name},
+            {"internal", game != games_result->end() &&
+                 game->visibility == GameVisibility::INTERNAL},
             {"name", item.name},
         });
     }
@@ -4121,7 +4218,8 @@ void App::handleSeriesNew(
         return;
     }
     inja::json games = inja::json::array();
-    auto games_result = data_source_->getGames();
+    auto games_result = data_source_->getGames(
+        GameContentScope::INCLUDE_INTERNAL);
     if(!games_result)
     {
         respondInternalError(response);
@@ -4131,6 +4229,7 @@ void App::handleSeriesNew(
     {
         games.push_back({
             {"display_name", game.display_name},
+            {"internal", game.visibility == GameVisibility::INTERNAL},
             {"short_name", game.short_name},
         });
     }
@@ -4217,7 +4316,8 @@ void App::handleSeriesEdit(
         respondNotFound(response);
         return;
     }
-    auto series_result = data_source_->getSeries(*id);
+    auto series_result = data_source_->getSeries(
+        *id, GameContentScope::INCLUDE_INTERNAL);
     if(!series_result)
     {
         spdlog::error(
@@ -4233,7 +4333,8 @@ void App::handleSeriesEdit(
         return;
     }
     const Series& series = **series_result;
-    auto game = data_source_->getGameDefinition(series.game_short_name);
+    auto game = data_source_->getGameDefinition(
+        series.game_short_name, GameContentScope::INCLUDE_INTERNAL);
     if(!game || !*game)
     {
         respondInternalError(response);
@@ -4326,7 +4427,8 @@ void App::handleSeriesDeleteConfirm(
         respondNotFound(response);
         return;
     }
-    auto series_result = data_source_->getSeries(*id);
+    auto series_result = data_source_->getSeries(
+        *id, GameContentScope::INCLUDE_INTERNAL);
     if(!series_result || !*series_result)
     {
         if(!series_result)
diff --git a/src/app.h b/src/app.h
index 3cfe9a3..e660667 100644
--- a/src/app.h
+++ b/src/app.h
@@ -263,6 +263,7 @@ private:
         const std::string& short_name,
         const std::string& display_name,
         const std::string& description,
+        GameVisibility visibility,
         bool editing);
 
     /// Re-render a field editor after definition validation fails.
diff --git a/src/asset_store.cpp b/src/asset_store.cpp
index bec17f1..2edfb22 100644
--- a/src/asset_store.cpp
+++ b/src/asset_store.cpp
@@ -327,7 +327,8 @@ mw::E<void> AssetStore::reconcile(DataSourceInterface& data_source) const
             continue;
         }
         auto identity = parsePublicId(recovery->public_id);
-        auto card = data_source.getCard(*identity);
+        auto card = data_source.getCard(
+            *identity, GameContentScope::INCLUDE_INTERNAL);
         if(!card)
         {
             return std::unexpected(std::move(card.error()));
@@ -393,7 +394,8 @@ mw::E<void> AssetStore::reconcile(DataSourceInterface& data_source) const
             continue;
         }
         auto identity = parsePublicId(recovery->public_id);
-        auto card = data_source.getCard(*identity);
+        auto card = data_source.getCard(
+            *identity, GameContentScope::INCLUDE_INTERNAL);
         if(!card)
         {
             return std::unexpected(std::move(card.error()));
@@ -431,7 +433,8 @@ mw::E<void> AssetStore::reconcile(DataSourceInterface& data_source) const
             spdlog::warn("Leaving non-card published entry {}", public_id);
             continue;
         }
-        auto card = data_source.getCard(*identity);
+        auto card = data_source.getCard(
+            *identity, GameContentScope::INCLUDE_INTERNAL);
         if(!card)
         {
             return std::unexpected(std::move(card.error()));
diff --git a/src/authorization.cpp b/src/authorization.cpp
index 9a61c30..a4847d6 100644
--- a/src/authorization.cpp
+++ b/src/authorization.cpp
@@ -36,3 +36,18 @@ bool AuthorizationService::canAdminister(const User& actor) const
 {
     return actor.role == UserRole::ADMINISTRATOR;
 }
+
+GameContentScope AuthorizationService::gameContentScope(
+    const User& actor) const
+{
+    return actor.role == UserRole::ADMINISTRATOR
+        ? GameContentScope::INCLUDE_INTERNAL
+        : GameContentScope::PUBLIC_ONLY;
+}
+
+bool AuthorizationService::canUseGame(
+    const User& actor, const Game& game) const
+{
+    return game.visibility == GameVisibility::PUBLIC ||
+           actor.role == UserRole::ADMINISTRATOR;
+}
diff --git a/src/authorization.h b/src/authorization.h
index 1129971..8d173f0 100644
--- a/src/authorization.h
+++ b/src/authorization.h
@@ -1,6 +1,7 @@
 #pragma once
 
 #include "card.h"
+#include "game.h"
 #include "user.h"
 
 /// Side-effect-free application permission policy.
@@ -25,4 +26,10 @@ public:
 
     /// Return whether an actor may manage series and users.
     bool canAdminister(const User& actor) const;
+
+    /// Return the game-content scope available to an actor.
+    GameContentScope gameContentScope(const User& actor) const;
+
+    /// Return whether an actor may use a game in a card mutation.
+    bool canUseGame(const User& actor, const Game& game) const;
 };
diff --git a/src/card_service.cpp b/src/card_service.cpp
index 89ac64c..09053c0 100644
--- a/src/card_service.cpp
+++ b/src/card_service.cpp
@@ -192,6 +192,10 @@ mw::E<std::string> CardService::createCard(
         {
             return std::unexpected(mw::httpError(422, "Unknown game"));
         }
+        if(!authorization_.canUseGame(**actor, (**definition).game))
+        {
+            return std::unexpected(mw::httpError(422, "Unknown game"));
+        }
         if((**definition).game.revision != *expected_game_revision)
         {
             return std::unexpected(mw::httpError(
@@ -328,7 +332,9 @@ mw::E<std::string> CardService::updateCard(
     SubmittedGameFields submitted_fields,
     const std::vector<std::int64_t>& series_ids)
 {
-    auto stored = data_source_.getCard(input.current_card.identity);
+    auto stored = data_source_.getCard(
+        input.current_card.identity,
+        GameContentScope::INCLUDE_INTERNAL);
     if(!stored)
     {
         return std::unexpected(std::move(stored.error()));
@@ -562,6 +568,10 @@ mw::E<std::string> CardService::updateCard(
             return std::unexpected(mw::runtimeError(
                 "Card references a missing game"));
         }
+        if(!authorization_.canUseGame(**actor, (**definition).game))
+        {
+            return std::unexpected(mw::httpError(404, "Card not found"));
+        }
         if((**definition).game.revision != *expected_game_revision)
         {
             return std::unexpected(mw::httpError(
diff --git a/src/data.cpp b/src/data.cpp
index 1c61c05..aed13fc 100644
--- a/src/data.cpp
+++ b/src/data.cpp
@@ -70,6 +70,7 @@ mw::E<bool> DataSourceTransactionInterface::updateGame(
     [[maybe_unused]] const std::string& short_name,
     [[maybe_unused]] const std::string& display_name,
     [[maybe_unused]] const std::string& description,
+    [[maybe_unused]] GameVisibility visibility,
     [[maybe_unused]] std::int64_t expected_revision)
 {
     return std::unexpected(mw::runtimeError(
@@ -296,7 +297,8 @@ mw::E<std::int64_t> DataSourceTransactionInterface::incrementHolding(
 }
 
 mw::E<std::vector<Card>> DataSourceInterface::getCardsByCreator(
-    [[maybe_unused]] std::int64_t creator_user_id) const
+    [[maybe_unused]] std::int64_t creator_user_id,
+    [[maybe_unused]] GameContentScope scope) const
 {
     return std::unexpected(mw::runtimeError("User data is unavailable"));
 }
@@ -342,7 +344,8 @@ DataSourceInterface::getAuthenticationChallenge(
 }
 
 mw::E<std::vector<CollectionEntry>> DataSourceInterface::getCollection(
-    [[maybe_unused]] std::int64_t user_id) const
+    [[maybe_unused]] std::int64_t user_id,
+    [[maybe_unused]] GameContentScope scope) const
 {
     return std::unexpected(mw::runtimeError(
         "Collection data is unavailable"));
@@ -372,21 +375,24 @@ mw::E<void> DataSourceInterface::cleanupAuthentication(
         "Authentication data is unavailable"));
 }
 
-mw::E<std::vector<Game>> DataSourceInterface::getGames() const
+mw::E<std::vector<Game>> DataSourceInterface::getGames(
+    [[maybe_unused]] GameContentScope scope) const
 {
     return std::unexpected(mw::runtimeError("Game data is unavailable"));
 }
 
 mw::E<std::optional<GameDefinitionSnapshot>>
 DataSourceInterface::getGameDefinition(
-    [[maybe_unused]] const std::string& short_name) const
+    [[maybe_unused]] const std::string& short_name,
+    [[maybe_unused]] GameContentScope scope) const
 {
     return std::unexpected(mw::runtimeError("Game data is unavailable"));
 }
 
 mw::E<std::optional<CardGameFields>>
 DataSourceInterface::getCardFieldValues(
-    [[maybe_unused]] std::int64_t card_id) const
+    [[maybe_unused]] std::int64_t card_id,
+    [[maybe_unused]] GameContentScope scope) const
 {
     return std::unexpected(mw::runtimeError("Game data is unavailable"));
 }
diff --git a/src/data.h b/src/data.h
index 23c35ff..9362564 100644
--- a/src/data.h
+++ b/src/data.h
@@ -108,6 +108,7 @@ public:
         const std::string& short_name,
         const std::string& display_name,
         const std::string& description,
+        GameVisibility visibility,
         std::int64_t expected_revision);
 
     /// Delete an unused game, its fields, choices, and sequence.
@@ -288,29 +289,38 @@ public:
     beginTransaction() = 0;
 
     /// Return all cards for the unpaginated index.
-    virtual mw::E<std::vector<Card>> getCards() const = 0;
+    virtual mw::E<std::vector<Card>> getCards(
+        GameContentScope scope) const = 0;
 
     /// Return all cards authored by one user.
     virtual mw::E<std::vector<Card>> getCardsByCreator(
-        std::int64_t creator_user_id) const;
+        std::int64_t creator_user_id,
+        GameContentScope scope) const;
 
     /// Return every current positive-rarity card in card-ID order.
     virtual mw::E<std::vector<Card>> getPoolCards() const;
 
     /// Return a card by its parsed identity.
     virtual mw::E<std::optional<Card>>
-    getCard(const CardIdentity& identity) const = 0;
+    getCard(
+        const CardIdentity& identity,
+        GameContentScope scope) const = 0;
 
     /// Return games ordered by display name and then short name.
-    virtual mw::E<std::vector<Game>> getGames() const;
+    virtual mw::E<std::vector<Game>> getGames(
+        GameContentScope scope) const;
 
     /// Return a complete, consistently read game definition.
     virtual mw::E<std::optional<GameDefinitionSnapshot>>
-    getGameDefinition(const std::string& short_name) const;
+    getGameDefinition(
+        const std::string& short_name,
+        GameContentScope scope) const;
 
     /// Return a card's definition and ordered custom values together.
     virtual mw::E<std::optional<CardGameFields>>
-    getCardFieldValues(std::int64_t card_id) const;
+    getCardFieldValues(
+        std::int64_t card_id,
+        GameContentScope scope) const;
 
     /// Return a user by internal identity.
     virtual mw::E<std::optional<User>> getUser(
@@ -334,7 +344,8 @@ public:
 
     /// Return a user's distinct collection entries.
     virtual mw::E<std::vector<CollectionEntry>> getCollection(
-        std::int64_t user_id) const;
+        std::int64_t user_id,
+        GameContentScope scope) const;
 
     /// Return whether a user currently owns a card.
     virtual mw::E<bool> userOwnsCard(
@@ -351,11 +362,14 @@ public:
     virtual mw::E<void> cleanupAuthentication(std::int64_t now);
 
     /// Return all series, ordered by game and name.
-    virtual mw::E<std::vector<Series>> getSeries() const = 0;
+    virtual mw::E<std::vector<Series>> getSeries(
+        GameContentScope scope) const = 0;
 
     /// Return one series by internal ID.
     virtual mw::E<std::optional<Series>>
-    getSeries(std::int64_t series_id) const = 0;
+    getSeries(
+        std::int64_t series_id,
+        GameContentScope scope) const = 0;
 
     /// Return the series memberships for one card.
     virtual mw::E<std::vector<std::int64_t>>
diff --git a/src/data_fake.cpp b/src/data_fake.cpp
index caa43ac..dbd7e97 100644
--- a/src/data_fake.cpp
+++ b/src/data_fake.cpp
@@ -51,13 +51,59 @@ DataSourceFake::beginTransaction()
     return std::unexpected(readOnlyError("begin a transaction"));
 }
 
-mw::E<std::vector<Card>> DataSourceFake::getCards() const
+bool DataSourceFake::gameVisible(
+    const std::string& short_name, GameContentScope scope) const
 {
-    return cards_;
+    if(scope == GameContentScope::INCLUDE_INTERNAL)
+    {
+        return true;
+    }
+    const auto definition = std::ranges::find_if(
+        game_definitions_,
+        [&short_name](const GameDefinitionSnapshot& candidate)
+        {
+            return candidate.game.short_name == short_name;
+        });
+    return definition != game_definitions_.end() &&
+           definition->game.visibility == GameVisibility::PUBLIC;
+}
+
+bool DataSourceFake::cardVisible(
+    const Card& card, GameContentScope scope) const
+{
+    return !card.identity.game_short_name ||
+           gameVisible(*card.identity.game_short_name, scope);
+}
+
+mw::E<std::vector<Card>> DataSourceFake::getCards(
+    GameContentScope scope) const
+{
+    std::vector<Card> cards;
+    std::ranges::copy_if(
+        cards_, std::back_inserter(cards),
+        [this, scope](const Card& card)
+        {
+            return cardVisible(card, scope);
+        });
+    return cards;
+}
+
+mw::E<std::vector<Card>> DataSourceFake::getCardsByCreator(
+    std::int64_t creator_user_id, GameContentScope scope) const
+{
+    std::vector<Card> cards;
+    std::ranges::copy_if(
+        cards_, std::back_inserter(cards),
+        [this, creator_user_id, scope](const Card& card)
+        {
+            return card.creator_user_id == creator_user_id &&
+                   cardVisible(card, scope);
+        });
+    return cards;
 }
 
 mw::E<std::optional<Card>> DataSourceFake::getCard(
-    const CardIdentity& identity) const
+    const CardIdentity& identity, GameContentScope scope) const
 {
     const auto card = std::ranges::find_if(
         cards_,
@@ -65,20 +111,24 @@ mw::E<std::optional<Card>> DataSourceFake::getCard(
         {
             return identitiesMatch(candidate.identity, identity);
         });
-    if(card == cards_.end())
+    if(card == cards_.end() || !cardVisible(*card, scope))
     {
         return std::nullopt;
     }
     return *card;
 }
 
-mw::E<std::vector<Game>> DataSourceFake::getGames() const
+mw::E<std::vector<Game>> DataSourceFake::getGames(
+    GameContentScope scope) const
 {
     std::vector<Game> games;
     games.reserve(game_definitions_.size());
     for(const GameDefinitionSnapshot& definition : game_definitions_)
     {
-        games.push_back(definition.game);
+        if(gameVisible(definition.game.short_name, scope))
+        {
+            games.push_back(definition.game);
+        }
     }
     std::ranges::sort(
         games,
@@ -94,7 +144,8 @@ mw::E<std::vector<Game>> DataSourceFake::getGames() const
 }
 
 mw::E<std::optional<GameDefinitionSnapshot>>
-DataSourceFake::getGameDefinition(const std::string& short_name) const
+DataSourceFake::getGameDefinition(
+    const std::string& short_name, GameContentScope scope) const
 {
     const auto definition = std::ranges::find_if(
         game_definitions_,
@@ -102,7 +153,8 @@ DataSourceFake::getGameDefinition(const std::string& short_name) const
         {
             return candidate.game.short_name == short_name;
         });
-    if(definition == game_definitions_.end())
+    if(definition == game_definitions_.end() ||
+       !gameVisible(short_name, scope))
     {
         return std::nullopt;
     }
@@ -110,16 +162,18 @@ DataSourceFake::getGameDefinition(const std::string& short_name) const
 }
 
 mw::E<std::optional<CardGameFields>> DataSourceFake::getCardFieldValues(
-    std::int64_t card_id) const
+    std::int64_t card_id, GameContentScope scope) const
 {
     const auto card = std::ranges::find_if(
         cards_,
         [card_id](const Card& candidate) { return candidate.id == card_id; });
-    if(card == cards_.end() || !card->identity.game_short_name)
+    if(card == cards_.end() || !card->identity.game_short_name ||
+       !cardVisible(*card, scope))
     {
         return std::nullopt;
     }
-    auto definition = getGameDefinition(*card->identity.game_short_name);
+    auto definition = getGameDefinition(
+        *card->identity.game_short_name, scope);
     if(!definition || !*definition)
     {
         return std::unexpected(mw::runtimeError(
@@ -133,13 +187,21 @@ mw::E<std::optional<CardGameFields>> DataSourceFake::getCardFieldValues(
             : values->second};
 }
 
-mw::E<std::vector<Series>> DataSourceFake::getSeries() const
+mw::E<std::vector<Series>> DataSourceFake::getSeries(
+    GameContentScope scope) const
 {
-    return series_;
+    std::vector<Series> series;
+    std::ranges::copy_if(
+        series_, std::back_inserter(series),
+        [this, scope](const Series& item)
+        {
+            return gameVisible(item.game_short_name, scope);
+        });
+    return series;
 }
 
 mw::E<std::optional<Series>> DataSourceFake::getSeries(
-    std::int64_t series_id) const
+    std::int64_t series_id, GameContentScope scope) const
 {
     const auto series = std::ranges::find_if(
         series_,
@@ -147,7 +209,8 @@ mw::E<std::optional<Series>> DataSourceFake::getSeries(
         {
             return candidate.id == series_id;
         });
-    if(series == series_.end())
+    if(series == series_.end() ||
+       !gameVisible(series->game_short_name, scope))
     {
         return std::nullopt;
     }
diff --git a/src/data_fake.h b/src/data_fake.h
index 70893e7..f63a52f 100644
--- a/src/data_fake.h
+++ b/src/data_fake.h
@@ -34,29 +34,43 @@ public:
     beginTransaction() override;
 
     /// Return all configured cards in insertion order.
-    mw::E<std::vector<Card>> getCards() const override;
+    mw::E<std::vector<Card>> getCards(
+        GameContentScope scope) const override;
+
+    /// Return configured cards authored by one user.
+    mw::E<std::vector<Card>> getCardsByCreator(
+        std::int64_t creator_user_id,
+        GameContentScope scope) const override;
 
     /// Return the configured card matching an identity.
     mw::E<std::optional<Card>>
-    getCard(const CardIdentity& identity) const override;
+    getCard(
+        const CardIdentity& identity,
+        GameContentScope scope) const override;
 
     /// Return configured games ordered by display name and short name.
-    mw::E<std::vector<Game>> getGames() const override;
+    mw::E<std::vector<Game>> getGames(
+        GameContentScope scope) const override;
 
     /// Return one configured complete game definition.
     mw::E<std::optional<GameDefinitionSnapshot>> getGameDefinition(
-        const std::string& short_name) const override;
+        const std::string& short_name,
+        GameContentScope scope) const override;
 
     /// Return a configured card's game definition and values.
     mw::E<std::optional<CardGameFields>> getCardFieldValues(
-        std::int64_t card_id) const override;
+        std::int64_t card_id,
+        GameContentScope scope) const override;
 
     /// Return all configured series in insertion order.
-    mw::E<std::vector<Series>> getSeries() const override;
+    mw::E<std::vector<Series>> getSeries(
+        GameContentScope scope) const override;
 
     /// Return the configured series with an internal ID.
     mw::E<std::optional<Series>>
-    getSeries(std::int64_t series_id) const override;
+    getSeries(
+        std::int64_t series_id,
+        GameContentScope scope) const override;
 
     /// Return configured series memberships for a card.
     mw::E<std::vector<std::int64_t>>
@@ -67,6 +81,12 @@ protected:
     mw::E<void> setSchemaVersion(std::int64_t version) override;
 
 private:
+    bool gameVisible(
+        const std::string& short_name,
+        GameContentScope scope) const;
+
+    bool cardVisible(const Card& card, GameContentScope scope) const;
+
     std::vector<Card> cards_;
     std::vector<Series> series_;
     std::unordered_map<std::int64_t, std::vector<std::int64_t>> card_series_;
diff --git a/src/data_sqlite.cpp b/src/data_sqlite.cpp
index 2989a5a..e065ec9 100644
--- a/src/data_sqlite.cpp
+++ b/src/data_sqlite.cpp
@@ -19,6 +19,32 @@
 namespace
 {
 
+std::int64_t gameVisibilityInteger(GameVisibility visibility)
+{
+    return static_cast<std::int64_t>(visibility);
+}
+
+mw::E<GameVisibility> gameVisibilityFromInteger(std::int64_t value)
+{
+    switch(value)
+    {
+    case 0:
+        return GameVisibility::PUBLIC;
+    case 1:
+        return GameVisibility::INTERNAL;
+    default:
+        return std::unexpected(mw::runtimeError(
+            "Database contains an invalid game visibility"));
+    }
+}
+
+std::string visibilityPredicate(GameContentScope scope)
+{
+    return scope == GameContentScope::PUBLIC_ONLY
+        ? " AND (card.game_short_name IS NULL OR game.visibility = 0)"
+        : "";
+}
+
 mw::E<void> rollbackWithError(
     mw::SQLite& connection,
     mw::Error error)
@@ -115,6 +141,8 @@ const std::vector<std::string_view> SCHEMA_VERSION_1_STATEMENTS = {
             short_name TEXT PRIMARY KEY,
             display_name TEXT NOT NULL,
             description TEXT NOT NULL DEFAULT '',
+            visibility INTEGER NOT NULL DEFAULT 0
+                CHECK(visibility IN (0, 1)),
             revision INTEGER NOT NULL DEFAULT 1 CHECK(revision >= 1),
             CHECK(length(short_name) > 0),
             CHECK(short_name NOT GLOB '*[^a-z0-9]*')
@@ -488,10 +516,11 @@ mw::E<std::vector<Card>> cardsFromRows(std::vector<CardRow> rows)
 
 mw::E<std::optional<GameDefinitionSnapshot>> loadGameDefinition(
     mw::SQLite& connection,
-    const std::string& short_name)
+    const std::string& short_name,
+    std::optional<GameContentScope> scope = std::nullopt)
 {
     auto game_statement = connection.statementFromStr(
-        "SELECT short_name, display_name, description, revision "
+        "SELECT short_name, display_name, description, visibility, revision "
         "FROM games WHERE short_name = ?;");
     if(!game_statement)
     {
@@ -503,7 +532,7 @@ mw::E<std::optional<GameDefinitionSnapshot>> loadGameDefinition(
         return std::unexpected(std::move(game_bind.error()));
     }
     auto game_rows = connection.eval<
-        std::string, std::string, std::string, std::int64_t>(
+        std::string, std::string, std::string, std::int64_t, std::int64_t>(
             std::move(*game_statement));
     if(!game_rows)
     {
@@ -514,16 +543,22 @@ mw::E<std::optional<GameDefinitionSnapshot>> loadGameDefinition(
         return std::optional<GameDefinitionSnapshot>{};
     }
 
-    auto& [stored_short_name, display_name, description, revision] =
-        game_rows->front();
-    if(revision < 1)
+    auto& [stored_short_name, display_name, description, visibility_value,
+           revision] = game_rows->front();
+    auto visibility = gameVisibilityFromInteger(visibility_value);
+    if(!visibility || revision < 1)
     {
         return std::unexpected(mw::runtimeError(
-            "Database contains an invalid game revision"));
+            "Database contains invalid game metadata"));
+    }
+    if(scope == GameContentScope::PUBLIC_ONLY &&
+       *visibility == GameVisibility::INTERNAL)
+    {
+        return std::optional<GameDefinitionSnapshot>{};
     }
     GameDefinitionSnapshot snapshot{
         {std::move(stored_short_name), std::move(display_name),
-         std::move(description), revision},
+         std::move(description), *visibility, revision},
         {}};
 
     auto field_statement = connection.statementFromStr(
@@ -725,14 +760,14 @@ public:
     {
         auto statement = connection_.statementFromStr(
             "INSERT INTO games(short_name, display_name, description, "
-            "revision) VALUES (?, ?, ?, ?);");
+            "visibility, revision) VALUES (?, ?, ?, ?, ?);");
         if(!statement)
         {
             return std::unexpected(std::move(statement.error()));
         }
         auto bind = statement->bind(
             game.short_name, game.display_name, game.description,
-            game.revision);
+            gameVisibilityInteger(game.visibility), game.revision);
         if(!bind)
         {
             return std::unexpected(std::move(bind.error()));
@@ -750,18 +785,20 @@ public:
         const std::string& short_name,
         const std::string& display_name,
         const std::string& description,
+        GameVisibility visibility,
         std::int64_t expected_revision) override
     {
         auto statement = connection_.statementFromStr(
             "UPDATE games SET display_name = ?, description = ?, "
-            "revision = revision + 1 "
+            "visibility = ?, revision = revision + 1 "
             "WHERE short_name = ? AND revision = ?;");
         if(!statement)
         {
             return std::unexpected(std::move(statement.error()));
         }
         auto bind = statement->bind(
-            display_name, description, short_name, expected_revision);
+            display_name, description, gameVisibilityInteger(visibility),
+            short_name, expected_revision);
         if(!bind)
         {
             return std::unexpected(std::move(bind.error()));
@@ -1137,7 +1174,10 @@ public:
     mw::E<std::vector<Card>> getPoolCardsForUpdate() override
     {
         return readCards(
-            "WHERE rarity > 0 ORDER BY id");
+            "LEFT JOIN games AS game "
+            "ON game.short_name = card.game_short_name "
+            "WHERE card.rarity > 0 AND (card.game_short_name IS NULL "
+            "OR game.visibility = 0) ORDER BY card.id");
     }
 
     /// Insert a newly confirmed account and return its internal ID.
@@ -2106,10 +2146,12 @@ private:
             std::optional<std::string>,
             std::string,
             std::int64_t>(
-                "SELECT id, creator_user_id, game_short_name, card_number, "
-                "name, short_description, long_description, rarity, "
-                "front_extension, foil_extension, thumbnail_extension, "
-                "revision FROM cards " + suffix + ";");
+                "SELECT card.id, card.creator_user_id, "
+                "card.game_short_name, card.card_number, card.name, "
+                "card.short_description, card.long_description, "
+                "card.rarity, card.front_extension, card.foil_extension, "
+                "card.thumbnail_extension, card.revision FROM cards AS card " +
+                suffix + ";");
         if(!rows)
         {
             return std::unexpected(std::move(rows.error()));
@@ -2233,14 +2275,16 @@ mw::E<std::int64_t> DataSourceSQLite::getSchemaVersion() const
     }
     if(*version == 1)
     {
-        auto users = connection_->evalToValue<int>(
+        auto current_shape = connection_->evalToValue<int>(
             "SELECT EXISTS(SELECT 1 FROM sqlite_schema "
-            "WHERE type = 'table' AND name = 'users');");
-        if(!users)
+            "WHERE type = 'table' AND name = 'users') AND "
+            "EXISTS(SELECT 1 FROM pragma_table_info('games') "
+            "WHERE name = 'visibility');");
+        if(!current_shape)
         {
-            return std::unexpected(std::move(users.error()));
+            return std::unexpected(std::move(current_shape.error()));
         }
-        if(*users == 0)
+        if(*current_shape == 0)
         {
             return std::unexpected(mw::runtimeError(
                 "Database uses the obsolete prototype schema version 1; "
@@ -2349,9 +2393,22 @@ DataSourceSQLite::beginTransaction()
         new DataSourceSQLiteTransaction(*connection_, std::move(lock)));
 }
 
-mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
+mw::E<std::vector<Card>> DataSourceSQLite::getCards(
+    GameContentScope scope) const
 {
     std::lock_guard lock(mutex_);
+    const std::string query =
+        "SELECT card.id, card.creator_user_id, card.game_short_name, "
+        "card.card_number, card.name, card.short_description, "
+        "card.long_description, card.rarity, card.front_extension, "
+        "card.foil_extension, card.thumbnail_extension, card.revision "
+        "FROM cards AS card" +
+        (scope == GameContentScope::PUBLIC_ONLY
+             ? std::string(" LEFT JOIN games AS game ON game.short_name = "
+                           "card.game_short_name")
+             : std::string()) +
+        " WHERE 1 = 1" +
+        visibilityPredicate(scope) + " ORDER BY card.id;";
     auto rows = connection_->eval<
         std::int64_t,
         std::int64_t,
@@ -2365,72 +2422,31 @@ mw::E<std::vector<Card>> DataSourceSQLite::getCards() const
         std::optional<std::string>,
         std::string,
         std::int64_t>(
-            "SELECT id, creator_user_id, game_short_name, card_number, name, "
-            "short_description, long_description, rarity, "
-            "front_extension, foil_extension, thumbnail_extension, "
-            "revision FROM cards ORDER BY id;");
+            query);
     if(!rows)
     {
         return std::unexpected(std::move(rows.error()));
     }
 
-    std::vector<Card> cards;
-    cards.reserve(rows->size());
-    for(auto& row : *rows)
-    {
-        auto& [
-            id,
-            creator_user_id,
-            game_short_name,
-            card_number,
-            name,
-            short_description,
-            long_description,
-            rarity,
-            front_extension,
-            foil_extension,
-            thumbnail_extension,
-            revision] = row;
-        const bool invalid_loose_number =
-            !game_short_name &&
-            card_number > std::numeric_limits<std::uint32_t>::max();
-        const bool invalid_game_number =
-            game_short_name &&
-            (game_short_name->empty() || card_number == 0);
-        if(card_number < 0 || invalid_loose_number || invalid_game_number ||
-           rarity < 0 || revision < 1)
-        {
-            return std::unexpected(mw::runtimeError(
-                "Database contains an invalid card record"));
-        }
-
-        cards.push_back({
-            id,
-            {std::move(game_short_name),
-             static_cast<std::uint64_t>(card_number)},
-            std::move(name),
-            std::move(short_description),
-            std::move(long_description),
-            rarity,
-            std::move(front_extension),
-            std::move(foil_extension),
-            std::move(thumbnail_extension),
-            revision,
-            creator_user_id,
-        });
-    }
-    return cards;
+    return cardsFromRows(std::move(*rows));
 }
 
 mw::E<std::vector<Card>> DataSourceSQLite::getCardsByCreator(
-    std::int64_t creator_user_id) const
+    std::int64_t creator_user_id, GameContentScope scope) const
 {
     std::lock_guard lock(mutex_);
     auto statement = connection_->statementFromStr(
-        "SELECT id, creator_user_id, game_short_name, card_number, name, "
-        "short_description, long_description, rarity, front_extension, "
-        "foil_extension, thumbnail_extension, revision FROM cards "
-        "WHERE creator_user_id = ? ORDER BY id;");
+        "SELECT card.id, card.creator_user_id, card.game_short_name, "
+        "card.card_number, card.name, card.short_description, "
+        "card.long_description, card.rarity, card.front_extension, "
+        "card.foil_extension, card.thumbnail_extension, card.revision "
+        "FROM cards AS card" +
+        (scope == GameContentScope::PUBLIC_ONLY
+             ? std::string(" LEFT JOIN games AS game ON game.short_name = "
+                           "card.game_short_name")
+             : std::string()) +
+        " WHERE card.creator_user_id = ?" + visibilityPredicate(scope) +
+        " ORDER BY card.id;");
     if(!statement)
     {
         return std::unexpected(std::move(statement.error()));
@@ -2461,10 +2477,14 @@ mw::E<std::vector<Card>> DataSourceSQLite::getPoolCards() const
         std::int64_t, std::string, std::optional<std::string>,
         std::optional<std::string>, std::int64_t, std::string,
         std::optional<std::string>, std::string, std::int64_t>(
-            "SELECT id, creator_user_id, game_short_name, card_number, "
-            "name, short_description, long_description, rarity, "
-            "front_extension, foil_extension, thumbnail_extension, "
-            "revision FROM cards WHERE rarity > 0 ORDER BY id;");
+            "SELECT card.id, card.creator_user_id, card.game_short_name, "
+            "card.card_number, card.name, card.short_description, "
+            "card.long_description, card.rarity, card.front_extension, "
+            "card.foil_extension, card.thumbnail_extension, card.revision "
+            "FROM cards AS card LEFT JOIN games AS game "
+            "ON game.short_name = card.game_short_name "
+            "WHERE card.rarity > 0 AND (card.game_short_name IS NULL "
+            "OR game.visibility = 0) ORDER BY card.id;");
     if(!rows)
     {
         return std::unexpected(std::move(rows.error()));
@@ -2473,16 +2493,23 @@ mw::E<std::vector<Card>> DataSourceSQLite::getPoolCards() const
 }
 
 mw::E<std::optional<Card>> DataSourceSQLite::getCard(
-    const CardIdentity& identity) const
+    const CardIdentity& identity, GameContentScope scope) const
 {
     std::lock_guard lock(mutex_);
     auto statement = connection_->statementFromStr(
-        "SELECT id, creator_user_id, game_short_name, card_number, name, "
-        "short_description, long_description, rarity, "
-        "front_extension, foil_extension, thumbnail_extension, "
-        "revision FROM cards WHERE "
-        "((? IS NULL AND game_short_name IS NULL) OR "
-        "game_short_name = ?) AND card_number = ?;");
+        "SELECT card.id, card.creator_user_id, card.game_short_name, "
+        "card.card_number, card.name, card.short_description, "
+        "card.long_description, card.rarity, card.front_extension, "
+        "card.foil_extension, card.thumbnail_extension, card.revision "
+        "FROM cards AS card" +
+        (scope == GameContentScope::PUBLIC_ONLY
+             ? std::string(" LEFT JOIN games AS game ON game.short_name = "
+                           "card.game_short_name")
+             : std::string()) +
+        " WHERE "
+        "((? IS NULL AND card.game_short_name IS NULL) OR "
+        "card.game_short_name = ?) AND card.card_number = ?" +
+        visibilityPredicate(scope) + ";");
     if(!statement)
     {
         return std::unexpected(std::move(statement.error()));
@@ -2547,47 +2574,59 @@ mw::E<std::optional<Card>> DataSourceSQLite::getCard(
     return std::optional<Card>(std::move(card));
 }
 
-mw::E<std::vector<Game>> DataSourceSQLite::getGames() const
+mw::E<std::vector<Game>> DataSourceSQLite::getGames(
+    GameContentScope scope) const
 {
     std::lock_guard lock(mutex_);
+    const std::string query =
+        "SELECT short_name, display_name, description, visibility, revision "
+        "FROM games" +
+        (scope == GameContentScope::PUBLIC_ONLY
+             ? std::string(" WHERE visibility = 0") : std::string()) +
+        " ORDER BY display_name, short_name;";
     auto rows = connection_->eval<
-        std::string, std::string, std::string, std::int64_t>(
-            "SELECT short_name, display_name, description, revision "
-            "FROM games ORDER BY display_name, short_name;");
+        std::string, std::string, std::string, std::int64_t, std::int64_t>(
+            query);
     if(!rows)
     {
         return std::unexpected(std::move(rows.error()));
     }
     std::vector<Game> games;
     games.reserve(rows->size());
-    for(auto& [short_name, display_name, description, revision] : *rows)
+    for(auto& [short_name, display_name, description, visibility_value,
+               revision] : *rows)
     {
-        if(revision < 1)
+        auto visibility = gameVisibilityFromInteger(visibility_value);
+        if(!visibility || revision < 1)
         {
             return std::unexpected(mw::runtimeError(
                 "Database contains an invalid game revision"));
         }
         games.push_back({
             std::move(short_name), std::move(display_name),
-            std::move(description), revision});
+            std::move(description), *visibility, revision});
     }
     return games;
 }
 
 mw::E<std::optional<GameDefinitionSnapshot>>
 DataSourceSQLite::getGameDefinition(
-    const std::string& short_name) const
+    const std::string& short_name, GameContentScope scope) const
 {
     std::lock_guard lock(mutex_);
-    return loadGameDefinition(*connection_, short_name);
+    return loadGameDefinition(*connection_, short_name, scope);
 }
 
 mw::E<std::optional<CardGameFields>>
-DataSourceSQLite::getCardFieldValues(std::int64_t card_id) const
+DataSourceSQLite::getCardFieldValues(
+    std::int64_t card_id, GameContentScope scope) const
 {
     std::lock_guard lock(mutex_);
     auto card_statement = connection_->statementFromStr(
-        "SELECT game_short_name FROM cards WHERE id = ?;");
+        "SELECT card.game_short_name FROM cards AS card "
+        "LEFT JOIN games AS game "
+        "ON game.short_name = card.game_short_name "
+        "WHERE card.id = ?" + visibilityPredicate(scope) + ";");
     if(!card_statement)
     {
         return std::unexpected(std::move(card_statement.error()));
@@ -2608,7 +2647,7 @@ DataSourceSQLite::getCardFieldValues(std::int64_t card_id) const
         return std::optional<CardGameFields>{};
     }
     const std::string& short_name = *std::get<0>(card_rows->front());
-    auto definition = loadGameDefinition(*connection_, short_name);
+    auto definition = loadGameDefinition(*connection_, short_name, scope);
     if(!definition)
     {
         return std::unexpected(std::move(definition.error()));
@@ -2874,7 +2913,7 @@ DataSourceSQLite::getAuthenticationChallenge(
 }
 
 mw::E<std::vector<CollectionEntry>> DataSourceSQLite::getCollection(
-    std::int64_t user_id) const
+    std::int64_t user_id, GameContentScope scope) const
 {
     std::lock_guard lock(mutex_);
     auto statement = connection_->statementFromStr(
@@ -2882,8 +2921,14 @@ mw::E<std::vector<CollectionEntry>> DataSourceSQLite::getCollection(
         "c.name, c.short_description, c.long_description, c.rarity, "
         "c.front_extension, c.foil_extension, c.thumbnail_extension, "
         "c.revision, h.quantity FROM card_holdings h "
-        "JOIN cards c ON c.id = h.card_id WHERE h.user_id = ? "
-        "ORDER BY c.id;");
+        "JOIN cards c ON c.id = h.card_id "
+        "LEFT JOIN games AS game ON game.short_name = c.game_short_name "
+        "WHERE h.user_id = ?" +
+        (scope == GameContentScope::PUBLIC_ONLY
+             ? std::string(
+                   " AND (c.game_short_name IS NULL OR game.visibility = 0)")
+             : std::string()) +
+        " ORDER BY c.id;");
     if(!statement)
     {
         return std::unexpected(std::move(statement.error()));
@@ -3156,16 +3201,23 @@ mw::E<void> DataSourceSQLite::cleanupAuthentication(std::int64_t now)
     return connection_->execute(std::move(*quota));
 }
 
-mw::E<std::vector<Series>> DataSourceSQLite::getSeries() const
+mw::E<std::vector<Series>> DataSourceSQLite::getSeries(
+    GameContentScope scope) const
 {
     std::lock_guard lock(mutex_);
+    const std::string query =
+        "SELECT series.id, series.game_short_name, series.name, "
+        "series.description FROM series JOIN games AS game "
+        "ON game.short_name = series.game_short_name" +
+        (scope == GameContentScope::PUBLIC_ONLY
+             ? std::string(" WHERE game.visibility = 0") : std::string()) +
+        " ORDER BY series.game_short_name, series.name, series.id;";
     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;");
+            query);
     if(!rows)
     {
         return std::unexpected(std::move(rows.error()));
@@ -3185,12 +3237,17 @@ mw::E<std::vector<Series>> DataSourceSQLite::getSeries() const
 }
 
 mw::E<std::optional<Series>> DataSourceSQLite::getSeries(
-    std::int64_t series_id) const
+    std::int64_t series_id, GameContentScope scope) const
 {
     std::lock_guard lock(mutex_);
     auto statement = connection_->statementFromStr(
-        "SELECT id, game_short_name, name, description "
-        "FROM series WHERE id = ?;");
+        "SELECT series.id, series.game_short_name, series.name, "
+        "series.description FROM series JOIN games AS game "
+        "ON game.short_name = series.game_short_name "
+        "WHERE series.id = ?" +
+        (scope == GameContentScope::PUBLIC_ONLY
+             ? std::string(" AND game.visibility = 0") : std::string()) +
+        ";");
     if(!statement)
     {
         return std::unexpected(std::move(statement.error()));
diff --git a/src/data_sqlite.h b/src/data_sqlite.h
index ba56641..1e79fa1 100644
--- a/src/data_sqlite.h
+++ b/src/data_sqlite.h
@@ -39,29 +39,38 @@ public:
     beginTransaction() override;
 
     /// Return all cards for the unpaginated index.
-    mw::E<std::vector<Card>> getCards() const override;
+    mw::E<std::vector<Card>> getCards(
+        GameContentScope scope) const override;
 
     /// Return all cards authored by one user.
     mw::E<std::vector<Card>> getCardsByCreator(
-        std::int64_t creator_user_id) const override;
+        std::int64_t creator_user_id,
+        GameContentScope scope) const override;
 
     /// Return every positive-rarity card in card-ID order.
     mw::E<std::vector<Card>> getPoolCards() const override;
 
     /// Return a card by its parsed identity.
     mw::E<std::optional<Card>>
-    getCard(const CardIdentity& identity) const override;
+    getCard(
+        const CardIdentity& identity,
+        GameContentScope scope) const override;
 
     /// Return all database-defined games ordered by display name.
-    mw::E<std::vector<Game>> getGames() const override;
+    mw::E<std::vector<Game>> getGames(
+        GameContentScope scope) const override;
 
     /// Return a complete, consistently read game definition.
     mw::E<std::optional<GameDefinitionSnapshot>>
-    getGameDefinition(const std::string& short_name) const override;
+    getGameDefinition(
+        const std::string& short_name,
+        GameContentScope scope) const override;
 
     /// Return a card's definition and ordered custom values together.
     mw::E<std::optional<CardGameFields>>
-    getCardFieldValues(std::int64_t card_id) const override;
+    getCardFieldValues(
+        std::int64_t card_id,
+        GameContentScope scope) const override;
 
     /// Return a user by internal identity.
     mw::E<std::optional<User>> getUser(
@@ -85,7 +94,8 @@ public:
 
     /// Return a user's distinct collection entries.
     mw::E<std::vector<CollectionEntry>> getCollection(
-        std::int64_t user_id) const override;
+        std::int64_t user_id,
+        GameContentScope scope) const override;
 
     /// Return whether a user currently owns a card.
     mw::E<bool> userOwnsCard(
@@ -102,11 +112,14 @@ public:
     mw::E<void> cleanupAuthentication(std::int64_t now) override;
 
     /// Return all series, ordered by game and name.
-    mw::E<std::vector<Series>> getSeries() const override;
+    mw::E<std::vector<Series>> getSeries(
+        GameContentScope scope) const override;
 
     /// Return one series by internal ID.
     mw::E<std::optional<Series>>
-    getSeries(std::int64_t series_id) const override;
+    getSeries(
+        std::int64_t series_id,
+        GameContentScope scope) const override;
 
     /// Return the series memberships for one card.
     mw::E<std::vector<std::int64_t>>
diff --git a/src/game.h b/src/game.h
index 4ef45d6..3b8ad35 100644
--- a/src/game.h
+++ b/src/game.h
@@ -14,6 +14,20 @@ enum class GameFieldType
     CHOICE
 };
 
+/// Application visibility assigned to a database-defined game.
+enum class GameVisibility
+{
+    PUBLIC = 0,
+    INTERNAL = 1
+};
+
+/// Visibility boundary for data derived from database-defined games.
+enum class GameContentScope
+{
+    PUBLIC_ONLY,
+    INCLUDE_INTERNAL
+};
+
 /// One database-defined game.
 struct Game
 {
@@ -26,6 +40,9 @@ struct Game
     /// Markdown game description.
     std::string description;
 
+    /// Application visibility for this game and its derived content.
+    GameVisibility visibility;
+
     /// Aggregate optimistic-concurrency revision.
     std::int64_t revision;
 };
diff --git a/src/game_service.cpp b/src/game_service.cpp
index ef9fa44..22ccbf9 100644
--- a/src/game_service.cpp
+++ b/src/game_service.cpp
@@ -186,7 +186,8 @@ mw::E<std::string> GameService::createGame(
     std::int64_t actor_user_id,
     std::string short_name,
     std::string display_name,
-    std::string description)
+    std::string description,
+    GameVisibility visibility)
 {
     if(!validShortName(short_name))
     {
@@ -222,7 +223,8 @@ mw::E<std::string> GameService::createGame(
             409, "A game with this short name already exists"));
     }
     auto inserted = (*transaction)->insertGame(
-        {short_name, std::move(display_name), std::move(description), 1});
+        {short_name, std::move(display_name), std::move(description),
+         visibility, 1});
     if(!inserted)
     {
         return std::unexpected(std::move(inserted.error()));
@@ -240,7 +242,8 @@ mw::E<void> GameService::updateGame(
     const std::string& short_name,
     std::int64_t expected_revision,
     std::string display_name,
-    std::string description)
+    std::string description,
+    GameVisibility visibility)
 {
     auto valid = validateDisplayText(
         display_name, description, markdown_renderer_);
@@ -266,7 +269,8 @@ mw::E<void> GameService::updateGame(
         return std::unexpected(std::move(definition.error()));
     }
     auto updated = (*transaction)->updateGame(
-        short_name, display_name, description, expected_revision);
+        short_name, display_name, description, visibility,
+        expected_revision);
     if(!updated)
     {
         return std::unexpected(std::move(updated.error()));
diff --git a/src/game_service.h b/src/game_service.h
index 955afe3..11e9e7d 100644
--- a/src/game_service.h
+++ b/src/game_service.h
@@ -32,7 +32,8 @@ public:
         std::int64_t actor_user_id,
         std::string short_name,
         std::string display_name,
-        std::string description);
+        std::string description,
+        GameVisibility visibility);
 
     /// Update a game's mutable presentation metadata.
     mw::E<void> updateGame(
@@ -40,7 +41,8 @@ public:
         const std::string& short_name,
         std::int64_t expected_revision,
         std::string display_name,
-        std::string description);
+        std::string description,
+        GameVisibility visibility);
 
     /// Delete a game only when it has never issued a card number.
     mw::E<void> removeGame(
diff --git a/src/series_service.cpp b/src/series_service.cpp
index a85b201..db3fda5 100644
--- a/src/series_service.cpp
+++ b/src/series_service.cpp
@@ -14,7 +14,8 @@ mw::E<void> rejectDuplicate(
     DataSourceInterface& data_source,
     const Series& candidate)
 {
-    auto series = data_source.getSeries();
+    auto series = data_source.getSeries(
+        GameContentScope::INCLUDE_INTERNAL);
     if(!series)
     {
         return std::unexpected(std::move(series.error()));
@@ -130,7 +131,8 @@ mw::E<void> SeriesService::update(
     std::string name,
     std::string description)
 {
-    auto current = data_source_.getSeries(series_id);
+    auto current = data_source_.getSeries(
+        series_id, GameContentScope::INCLUDE_INTERNAL);
     if(!current)
     {
         return std::unexpected(std::move(current.error()));
@@ -183,7 +185,8 @@ mw::E<void> SeriesService::remove(
     std::int64_t actor_user_id,
     std::int64_t series_id)
 {
-    auto current = data_source_.getSeries(series_id);
+    auto current = data_source_.getSeries(
+        series_id, GameContentScope::INCLUDE_INTERNAL);
     if(!current)
     {
         return std::unexpected(std::move(current.error()));
diff --git a/static/css/styles.css b/static/css/styles.css
index aabdc6d..d7cf7c4 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -1931,6 +1931,94 @@ h1 {
     text-transform: uppercase;
 }
 
+.internal-badge {
+    display: inline-flex;
+    align-items: center;
+    min-height: 1.75rem;
+    padding: 0.25rem 0.7rem;
+    border: 1px solid rgb(219 39 119 / 18%);
+    border-radius: 999px;
+    background: rgb(245 158 11 / 15%);
+    box-shadow:
+        4px 4px 9px rgb(160 150 180 / 16%),
+        -3px -3px 7px rgb(255 255 255 / 82%),
+        inset 2px 2px 4px rgb(255 255 255 / 40%),
+        inset -2px -2px 4px rgb(219 39 119 / 8%);
+    color: #9d174d;
+    font-size: 0.7rem;
+    font-weight: 900;
+    letter-spacing: 0.06em;
+    line-height: 1;
+    text-transform: uppercase;
+}
+
+.visibility-options {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 1rem;
+    margin: 1.5rem 0;
+    padding: 0;
+    border: 0;
+}
+
+.visibility-options legend {
+    grid-column: 1 / -1;
+    margin-bottom: 0.2rem;
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-size: 0.9rem;
+    font-weight: 900;
+}
+
+.visibility-option {
+    display: flex;
+    align-items: center;
+    min-height: 4.5rem;
+    gap: 0.8rem;
+    padding: 0.9rem 1rem;
+    border-radius: 1.25rem;
+    background: #efebf5;
+    box-shadow: var(--clay-pressed-shadow);
+    cursor: pointer;
+}
+
+.visibility-option:has(input:checked) {
+    background: rgb(255 255 255 / 84%);
+    outline: 0.2rem solid rgb(124 58 237 / 18%);
+}
+
+.visibility-option:has(input:focus-visible) {
+    outline: 0.25rem solid rgb(124 58 237 / 30%);
+}
+
+.visibility-option input {
+    width: 1.25rem;
+    height: 1.25rem;
+    margin: 0;
+    accent-color: var(--violet);
+}
+
+.visibility-option span {
+    display: grid;
+    gap: 0.2rem;
+}
+
+.visibility-option strong {
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-weight: 900;
+}
+
+.visibility-option small {
+    color: var(--muted);
+    font-weight: 600;
+    line-height: 1.35;
+}
+
+@media(max-width: 42rem) {
+    .visibility-options {
+        grid-template-columns: 1fr;
+    }
+}
+
 .game-editor {
     width: 100%;
 }
diff --git a/templates/card_index.html b/templates/card_index.html
index c92a17b..6ef97dc 100644
--- a/templates/card_index.html
+++ b/templates/card_index.html
@@ -45,6 +45,9 @@
                  loading="lazy">
         </a>
         <div class="card-details">
+            {% if card.internal %}
+            <span class="internal-badge">Internal</span>
+            {% endif %}
             <a class="card-id" href="{{ card.url }}">
                 {{ card.display_id }}
             </a>
diff --git a/templates/game_admin.html b/templates/game_admin.html
index 2dee589..5b33d76 100644
--- a/templates/game_admin.html
+++ b/templates/game_admin.html
@@ -38,6 +38,9 @@
                 <div>
                     <p class="eyebrow">{{ game.short_name }}</p>
                     <h2>{{ game.display_name }}</h2>
+                    {% if game.internal %}
+                    <span class="internal-badge">Internal</span>
+                    {% endif %}
                 </div>
             </header>
             {% if game.has_description %}
diff --git a/templates/game_form.html b/templates/game_form.html
index 666a101..ac63bcd 100644
--- a/templates/game_form.html
+++ b/templates/game_form.html
@@ -7,6 +7,9 @@
         <div>
             <p class="eyebrow">Game administration</p>
             <h1 id="GameFormHeading">{{ heading }}</h1>
+            {% if visibility_internal %}
+            <span class="internal-badge">Internal</span>
+            {% endif %}
         </div>
         {% if mode == "edit" %}
         <div class="management-actions">
@@ -79,6 +82,28 @@
             {% endif %}
         </label>
         <p class="form-hint">Descriptions support Markdown.</p>
+        <fieldset class="visibility-options">
+            <legend>Visibility</legend>
+            <label class="visibility-option">
+                <input name="visibility" type="radio" value="PUBLIC"
+                       {% if visibility_public %}checked{% endif %} required>
+                <span>
+                    <strong>Public</strong>
+                    <small>Available through normal card workflows.</small>
+                </span>
+            </label>
+            <label class="visibility-option">
+                <input name="visibility" type="radio" value="INTERNAL"
+                       {% if visibility_internal %}checked{% endif %}>
+                <span>
+                    <strong>Internal</strong>
+                    <small>
+                        Only the administrator can see this game and its
+                        cards.
+                    </small>
+                </span>
+            </label>
+        </fieldset>
         <button class="form-submit" type="submit">{{ submit_label }}</button>
     </form>
 
diff --git a/templates/series_form.html b/templates/series_form.html
index 20f6c53..c1917ab 100644
--- a/templates/series_form.html
+++ b/templates/series_form.html
@@ -16,6 +16,7 @@
                 {% for item in games %}
                 <option value="{{ item.short_name }}">
                     {{ item.display_name }}
+                    {% if item.internal %} (Internal){% endif %}
                 </option>
                 {% endfor %}
             </select>
diff --git a/templates/series_index.html b/templates/series_index.html
index 7c57c0e..c0bcc28 100644
--- a/templates/series_index.html
+++ b/templates/series_index.html
@@ -29,6 +29,9 @@
         <li>
             <div>
                 <p class="eyebrow">{{ item.game }}</p>
+                {% if item.internal %}
+                <span class="internal-badge">Internal</span>
+                {% endif %}
                 <h2>{{ item.name }}</h2>
                 {% if item.has_description %}
                 <div class="description-copy">{{ item.description }}</div>
diff --git a/tests/app_integration_test.cpp b/tests/app_integration_test.cpp
index f546e42..c0f80e3 100644
--- a/tests/app_integration_test.cpp
+++ b/tests/app_integration_test.cpp
@@ -139,7 +139,8 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     auto transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
     ASSERT_TRUE((*transaction)->insertGame(
-        {"test", "Test Game", "Dynamic extension test game.", 1}));
+        {"test", "Test Game", "Dynamic extension test game.",
+         GameVisibility::PUBLIC, 1}));
     auto hp_id = (*transaction)->insertGameField(
         {0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
     auto attack_id = (*transaction)->insertGameField(
@@ -357,6 +358,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         {"short_name", "dynamic"},
         {"display_name", "Dynamic Game"},
         {"description", "A **dynamic** game."},
+        {"visibility", "PUBLIC"},
     };
     const httplib::Params missing_csrf_game_fields = {
         {"short_name", "forbidden"},
@@ -375,6 +377,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         {"short_name", "Invalid-Game"},
         {"display_name", "Invalid Game"},
         {"description", ""},
+        {"visibility", "PUBLIC"},
     };
     auto invalid_game = client.Post(
         "/collection/admin/games",
@@ -390,6 +393,31 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     EXPECT_NE(invalid_game->body.find("Invalid-Game"),
               std::string::npos);
 
+    const httplib::Params invalid_visibility_fields = {
+        {"csrf_token", csrf_token},
+        {"short_name", "private"},
+        {"display_name", "Private"},
+        {"description", ""},
+        {"visibility", "PRIVATE"},
+    };
+    auto invalid_visibility = client.Post(
+        "/collection/admin/games",
+        session_headers,
+        invalid_visibility_fields);
+    ASSERT_NE(invalid_visibility, nullptr);
+    EXPECT_EQ(invalid_visibility->status, 400);
+
+    httplib::Params duplicate_visibility_fields = invalid_visibility_fields;
+    duplicate_visibility_fields.erase("visibility");
+    duplicate_visibility_fields.emplace("visibility", "PUBLIC");
+    duplicate_visibility_fields.emplace("visibility", "INTERNAL");
+    auto duplicate_visibility = client.Post(
+        "/collection/admin/games",
+        session_headers,
+        duplicate_visibility_fields);
+    ASSERT_NE(duplicate_visibility, nullptr);
+    EXPECT_EQ(duplicate_visibility->status, 400);
+
     auto dynamic_game = client.Post(
         "/collection/admin/games",
         session_headers,
@@ -480,6 +508,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         {"game_revision", "5"},
         {"display_name", "Dynamic Renamed"},
         {"description", "A **rendered** description."},
+        {"visibility", "PUBLIC"},
     };
     auto game_updated = client.Post(
         "/collection/admin/games/dynamic",
@@ -506,6 +535,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         {"game_revision", "4"},
         {"display_name", "Stale name"},
         {"description", ""},
+        {"visibility", "PUBLIC"},
     };
     auto stale_game = client.Post(
         "/collection/admin/games/dynamic",
@@ -621,6 +651,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         {"game_revision", "6"},
         {"display_name", "Dynamic Final"},
         {"description", "Final description."},
+        {"visibility", "PUBLIC"},
     };
     auto second_game_update = client.Post(
         "/collection/admin/games/dynamic",
@@ -725,6 +756,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         {"short_name", "empty"},
         {"display_name", "Empty Game"},
         {"description", ""},
+        {"visibility", "PUBLIC"},
     };
     auto empty_game = client.Post(
         "/collection/admin/games", session_headers, empty_game_fields);
@@ -758,6 +790,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         {"short_name", "gh"},
         {"display_name", "Going Home"},
         {"description", ""},
+        {"visibility", "PUBLIC"},
     };
     auto going_home = client.Post(
         "/collection/admin/games", session_headers, going_home_fields);
@@ -807,15 +840,6 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     EXPECT_NE(edited_page->body.find("Going Home"), std::string::npos);
     EXPECT_NE(edited_page->body.find("Edited upload"), std::string::npos);
 
-    auto deleted = client.Post(
-        created_path + "/delete",
-        session_headers,
-        csrf_fields);
-    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 = {
         {"csrf_token", csrf_token},
         {"game", "gh"},
@@ -926,6 +950,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         {"short_name", "playergame"},
         {"display_name", "Player Game"},
         {"description", ""},
+        {"visibility", "PUBLIC"},
     };
     auto player_game_create = client.Post(
         "/collection/admin/games",
@@ -934,6 +959,37 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     ASSERT_NE(player_game_create, nullptr);
     EXPECT_EQ(player_game_create->status, 403);
 
+    const httplib::Params internal_game_fields = {
+        {"csrf_token", csrf_token},
+        {"game_revision", "1"},
+        {"display_name", "Going Home"},
+        {"description", ""},
+        {"visibility", "INTERNAL"},
+    };
+    auto internal_game = client.Post(
+        "/collection/admin/games/gh",
+        session_headers,
+        internal_game_fields);
+    ASSERT_NE(internal_game, nullptr);
+    ASSERT_EQ(internal_game->status, 303) << internal_game->body;
+    auto concealed_card = client.Get(created_path, player_headers);
+    ASSERT_NE(concealed_card, nullptr);
+    EXPECT_EQ(concealed_card->status, 404);
+    EXPECT_EQ(
+        concealed_card->get_header_value("Cache-Control"),
+        "private, no-store");
+    auto administrator_cards = client.Get(
+        "/collection/admin/cards", session_headers);
+    ASSERT_NE(administrator_cards, nullptr);
+    ASSERT_EQ(administrator_cards->status, 200);
+    EXPECT_NE(administrator_cards->body.find("Internal"),
+              std::string::npos);
+    auto administrator_card = client.Get(created_path, session_headers);
+    ASSERT_NE(administrator_card, nullptr);
+    EXPECT_EQ(administrator_card->status, 200);
+    EXPECT_NE(administrator_card->body.find(
+        "Not currently in the pull pool"), std::string::npos);
+
     auto promoted = client.Post(
         "/collection/admin/users/2/promote",
         session_headers,
@@ -950,6 +1006,19 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     ASSERT_NE(creator_new, nullptr);
     ASSERT_EQ(creator_new->status, 200) << creator_new->body;
     EXPECT_EQ(creator_new->body.find("name=\"rarity\""), std::string::npos);
+    EXPECT_EQ(creator_new->body.find("Going Home"), std::string::npos);
+    const httplib::UploadFormDataItems forged_internal_fields = {
+        {"csrf_token", player_csrf, "", ""},
+        {"game", "gh", "", ""},
+        {"game_revision", "2", "", ""},
+        {"name", "Hidden creator card", "", ""},
+        {"source_mode", "files", "", ""},
+        {"front", upload_bytes, "card.png", "image/png"},
+    };
+    auto forged_internal = client.Post(
+        "/collection/cards", player_headers, forged_internal_fields);
+    ASSERT_NE(forged_internal, nullptr);
+    EXPECT_EQ(forged_internal->status, 422) << forged_internal->body;
     const httplib::UploadFormDataItems creator_card_fields = {
         {"csrf_token", player_csrf, "", ""},
         {"game", "", "", ""},
@@ -994,6 +1063,14 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     ASSERT_NE(owned_card, nullptr);
     EXPECT_EQ(owned_card->status, 200) << owned_card->body;
     EXPECT_NE(owned_card->body.find("100%"), std::string::npos);
+    auto deleted = client.Post(
+        created_path + "/delete",
+        session_headers,
+        csrf_fields);
+    ASSERT_NE(deleted, nullptr);
+    EXPECT_EQ(deleted->status, 303) << deleted->body;
+    EXPECT_FALSE(std::filesystem::exists(
+        config.card_storage_root / "published" / public_id));
     const httplib::Params player_logout_fields = {
         {"csrf_token", player_csrf}};
     auto logged_out = client.Post(
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index c18328f..ddd7d20 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -92,7 +92,7 @@ public:
     /// Return configured cards as the focused test pool.
     mw::E<std::vector<Card>> getPoolCards() const override
     {
-        return getCards();
+        return getCards(GameContentScope::INCLUDE_INTERNAL);
     }
 };
 
@@ -172,7 +172,11 @@ TEST(AppTest, RendersCardIndex)
         std::vector<Card>{
             makeCard(10, 10, "Tenth card"),
             makeCard(2, 2, "<script>Second card</script>"),
-        });
+        },
+        std::vector<Series>{},
+        std::unordered_map<std::int64_t, std::vector<std::int64_t>>{},
+        std::vector<GameDefinitionSnapshot>{
+            {{"pkm", "Test Game", "", GameVisibility::PUBLIC, 1}, {}}});
     App app(
         makeConfig("https://example.test/collection/"),
         std::move(data_source),
@@ -277,7 +281,7 @@ TEST(AppTest, RendersCardView)
             {2, {7}},
         },
         std::vector<GameDefinitionSnapshot>{
-            {{"test", "Test Game", "", 1}, {}}},
+            {{"test", "Test Game", "", GameVisibility::PUBLIC, 1}, {}}},
         std::unordered_map<std::int64_t, std::vector<GameFieldValue>>{});
     App app(
         makeConfig("https://example.test/collection/"),
diff --git a/tests/card_service_test.cpp b/tests/card_service_test.cpp
index c7ae6f7..0150822 100644
--- a/tests/card_service_test.cpp
+++ b/tests/card_service_test.cpp
@@ -144,13 +144,15 @@ TEST(CardServiceTest, CreatesLooseCard)
     });
 
     ASSERT_TRUE(public_id) << public_id.error().msg();
-    auto cards = (*data_source)->getCards();
+    auto cards = (*data_source)->getCards(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(cards);
     ASSERT_EQ(cards->size(), 1);
     EXPECT_EQ(cards->front().name, "Moon card");
     EXPECT_EQ(cards->front().front_extension, "avif");
     EXPECT_EQ(cards->front().thumbnail_extension, "avif");
-    auto stored_card = (*data_source)->getCard(cards->front().identity);
+    auto stored_card = (*data_source)->getCard(
+        cards->front().identity, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(stored_card);
     ASSERT_TRUE(*stored_card);
     EXPECT_EQ((**stored_card).id, cards->front().id);
@@ -199,7 +201,8 @@ TEST(CardServiceTest, RejectsInvalidImage)
     });
 
     EXPECT_FALSE(public_id);
-    auto cards = (*data_source)->getCards();
+    auto cards = (*data_source)->getCards(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(cards);
     EXPECT_TRUE(cards->empty());
     EXPECT_FALSE(std::filesystem::exists(temporary.path() / "published"));
@@ -241,7 +244,8 @@ TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
     });
 
     ASSERT_TRUE(public_id) << public_id.error().msg();
-    auto cards = (*data_source)->getCards();
+    auto cards = (*data_source)->getCards(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(cards);
     ASSERT_EQ(cards->size(), 1);
     EXPECT_EQ(cards->front().foil_extension, "jpg");
@@ -262,7 +266,7 @@ TEST(CardServiceTest, CreatesDynamicGameCard)
     auto series_transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(series_transaction);
     ASSERT_TRUE((*series_transaction)->insertGame(
-        {"test", "Test Game", "", 1}));
+        {"test", "Test Game", "", GameVisibility::PUBLIC, 1}));
     auto hp_id = (*series_transaction)->insertGameField(
         {0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
     auto attack_id = (*series_transaction)->insertGameField(
@@ -303,13 +307,15 @@ TEST(CardServiceTest, CreatesDynamicGameCard)
 
     ASSERT_TRUE(public_id) << public_id.error().msg();
     EXPECT_EQ(*public_id, "test-1");
-    auto stored = (*data_source)->getCard({"test", 1});
+    auto stored = (*data_source)->getCard(
+        {"test", 1}, GameContentScope::INCLUDE_INTERNAL);
     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 values = (*data_source)->getCardFieldValues((**stored).id);
+    auto values = (*data_source)->getCardFieldValues(
+        (**stored).id, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(values);
     ASSERT_TRUE(*values);
     EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 50);
@@ -336,11 +342,13 @@ TEST(CardServiceTest, CreatesDynamicGameCard)
         {{"hp", "80"}, {"attack", "40"}},
         {});
     ASSERT_TRUE(updated) << updated.error().msg();
-    stored = (*data_source)->getCard({"test", 1});
+    stored = (*data_source)->getCard(
+        {"test", 1}, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(stored);
     ASSERT_TRUE(*stored);
     EXPECT_EQ((**stored).name, "Updated game card");
-    values = (*data_source)->getCardFieldValues((**stored).id);
+    values = (*data_source)->getCardFieldValues(
+        (**stored).id, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(values);
     ASSERT_TRUE(*values);
     EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 80);
@@ -360,7 +368,8 @@ TEST(CardServiceTest, RollsBackDynamicMetadataFailures)
 
     auto setup = (*data_source)->beginTransaction();
     ASSERT_TRUE(setup);
-    ASSERT_TRUE((*setup)->insertGame({"test", "Test Game", "", 1}));
+    ASSERT_TRUE((*setup)->insertGame(
+        {"test", "Test Game", "", GameVisibility::PUBLIC, 1}));
     auto hp_id = (*setup)->insertGameField(
         {0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
     ASSERT_TRUE(hp_id);
@@ -403,7 +412,8 @@ TEST(CardServiceTest, RollsBackDynamicMetadataFailures)
         {{"hp", "50"}},
         {*series_id});
     ASSERT_FALSE(failed_create);
-    auto cards = (*data_source)->getCards();
+    auto cards = (*data_source)->getCards(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(cards);
     EXPECT_TRUE(cards->empty());
     EXPECT_FALSE(std::filesystem::exists(
@@ -430,7 +440,8 @@ TEST(CardServiceTest, RollsBackDynamicMetadataFailures)
         {*series_id});
     ASSERT_TRUE(public_id) << public_id.error().msg();
     EXPECT_EQ(*public_id, "test-1");
-    auto stored = (*data_source)->getCard({"test", 1});
+    auto stored = (*data_source)->getCard(
+        {"test", 1}, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(stored);
     ASSERT_TRUE(*stored);
     const std::filesystem::path published =
@@ -467,12 +478,14 @@ TEST(CardServiceTest, RollsBackDynamicMetadataFailures)
         {{"hp", "80"}},
         {});
     ASSERT_FALSE(failed_update);
-    stored = (*data_source)->getCard({"test", 1});
+    stored = (*data_source)->getCard(
+        {"test", 1}, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(stored);
     ASSERT_TRUE(*stored);
     EXPECT_EQ((**stored).name, "Committed card");
     EXPECT_EQ((**stored).revision, 1);
-    auto values = (*data_source)->getCardFieldValues((**stored).id);
+    auto values = (*data_source)->getCardFieldValues(
+        (**stored).id, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(values);
     ASSERT_TRUE(*values);
     ASSERT_EQ((**values).values.size(), 1);
@@ -517,7 +530,8 @@ TEST(CardServiceTest, UpdatesLooseCard)
         std::nullopt,
     });
     ASSERT_TRUE(public_id);
-    auto cards = (*data_source)->getCards();
+    auto cards = (*data_source)->getCards(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(cards);
     ASSERT_EQ(cards->size(), 1);
     const Card original_card = cards->front();
@@ -541,7 +555,8 @@ TEST(CardServiceTest, UpdatesLooseCard)
     });
     ASSERT_TRUE(metadata_update) << metadata_update.error().msg();
     EXPECT_EQ(*metadata_update, *public_id);
-    cards = (*data_source)->getCards();
+    cards = (*data_source)->getCards(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(cards);
     ASSERT_EQ(cards->size(), 1);
     EXPECT_EQ(cards->front().name, "Edited metadata");
@@ -595,7 +610,8 @@ TEST(CardServiceTest, UpdatesLooseCard)
         std::nullopt,
     });
     ASSERT_TRUE(artwork_update) << artwork_update.error().msg();
-    cards = (*data_source)->getCards();
+    cards = (*data_source)->getCards(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(cards);
     ASSERT_EQ(cards->size(), 1);
     EXPECT_EQ(cards->front().front_extension, "jpg");
@@ -609,7 +625,8 @@ TEST(CardServiceTest, UpdatesLooseCard)
 
     auto deleted = service.deleteCard(1, cards->front());
     ASSERT_TRUE(deleted) << deleted.error().msg();
-    cards = (*data_source)->getCards();
+    cards = (*data_source)->getCards(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(cards);
     EXPECT_TRUE(cards->empty());
     EXPECT_FALSE(std::filesystem::exists(published));
@@ -823,7 +840,8 @@ TEST(CardServiceTest, EnforcesCreatorOwnershipAndRarity)
         false};
     auto public_id = service.createLooseCard(*creator_id, std::move(input));
     ASSERT_TRUE(public_id) << public_id.error().msg();
-    auto cards = (*data_source)->getCards();
+    auto cards = (*data_source)->getCards(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(cards);
     ASSERT_EQ(cards->size(), 1);
     EXPECT_EQ(cards->front().creator_user_id, *creator_id);
@@ -855,3 +873,107 @@ TEST(CardServiceTest, EnforcesCreatorOwnershipAndRarity)
     ASSERT_NE(http_error, nullptr);
     EXPECT_EQ(http_error->code, 403);
 }
+
+TEST(CardServiceTest, RejectsCreatorMutationsForInternalGames)
+{
+    initializeImageMagick();
+    TemporaryCardRoot temporary;
+    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
+    auto data_source = prepareTestDataSource(database);
+    ASSERT_TRUE(data_source);
+    auto transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    User creator = {
+        0,
+        "creator@example.com",
+        "creator@example.com",
+        std::nullopt,
+        UserRole::CREATOR,
+        1,
+        0,
+        0};
+    auto creator_id = (*transaction)->insertUser(creator);
+    ASSERT_TRUE(creator_id);
+    ASSERT_TRUE((*transaction)->updateUsername(
+        *creator_id, "Creator", "creator"));
+    ASSERT_TRUE((*transaction)->insertGame(
+        {"internal", "Internal", "", GameVisibility::INTERNAL, 1}));
+    ASSERT_TRUE((*transaction)->insertGame(
+        {"public", "Public", "", GameVisibility::PUBLIC, 1}));
+    ASSERT_TRUE((*transaction)->commit());
+
+    NonSecretRandom random(7);
+    CardService service(
+        **data_source,
+        random,
+        ImageProcessor(75, 256),
+        AssetStore(temporary.path()));
+    const std::filesystem::path rejected_staging =
+        temporary.path() / ".staging/rejected";
+    std::filesystem::create_directories(rejected_staging);
+    const std::filesystem::path rejected_front =
+        rejected_staging / "upload_front";
+    writePng(rejected_front);
+    auto rejected = service.createGameCard(
+        *creator_id,
+        {"Rejected", std::nullopt, std::nullopt, 0, rejected_staging,
+         rejected_front, std::nullopt, std::nullopt, false},
+        "internal",
+        1,
+        {},
+        {});
+    ASSERT_FALSE(rejected);
+    const auto* create_error = rejected.error().as<mw::HTTPError>();
+    ASSERT_NE(create_error, nullptr);
+    EXPECT_EQ(create_error->code, 422);
+    EXPECT_EQ(create_error->msg, "Unknown game");
+
+    const std::filesystem::path public_staging =
+        temporary.path() / ".staging/public";
+    std::filesystem::create_directories(public_staging);
+    const std::filesystem::path public_front =
+        public_staging / "upload_front";
+    writePng(public_front);
+    auto created = service.createGameCard(
+        *creator_id,
+        {"Creator card", std::nullopt, std::nullopt, 0, public_staging,
+         public_front, std::nullopt, std::nullopt, false},
+        "public",
+        1,
+        {},
+        {});
+    ASSERT_TRUE(created) << created.error().msg();
+    auto stored = (*data_source)->getCard(
+        {"public", 1}, GameContentScope::INCLUDE_INTERNAL);
+    ASSERT_TRUE(stored);
+    ASSERT_TRUE(*stored);
+
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    auto hidden = (*transaction)->updateGame(
+        "public", "Public", "", GameVisibility::INTERNAL, 1);
+    ASSERT_TRUE(hidden);
+    ASSERT_TRUE(*hidden);
+    ASSERT_TRUE((*transaction)->commit());
+
+    const std::filesystem::path edit_staging =
+        temporary.path() / ".staging/edit";
+    std::filesystem::create_directories(edit_staging);
+    auto updated = service.updateGameCard(
+        *creator_id,
+        {**stored, 1, "Hidden edit", std::nullopt, std::nullopt, 0,
+         edit_staging, FrontAssetAction::KEEP, FoilAssetAction::KEEP,
+         std::nullopt, std::nullopt, std::nullopt, false},
+        2,
+        {},
+        {});
+    ASSERT_FALSE(updated);
+    const auto* update_error = updated.error().as<mw::HTTPError>();
+    ASSERT_NE(update_error, nullptr);
+    EXPECT_EQ(update_error->code, 404);
+    stored = (*data_source)->getCard(
+        {"public", 1}, GameContentScope::INCLUDE_INTERNAL);
+    ASSERT_TRUE(stored);
+    ASSERT_TRUE(*stored);
+    EXPECT_EQ((**stored).name, "Creator card");
+}
diff --git a/tests/data_fake_test.cpp b/tests/data_fake_test.cpp
index 2cb9233..8b080a7 100644
--- a/tests/data_fake_test.cpp
+++ b/tests/data_fake_test.cpp
@@ -42,16 +42,19 @@ TEST(DataSourceFakeTest, ReturnsCards)
         makeCard(2, "test", 3, "Game card"),
     });
 
-    const auto cards = data_source.getCards();
+    const auto cards = data_source.getCards(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(cards);
     ASSERT_EQ(cards->size(), 2U);
 
-    const auto card = data_source.getCard({"test", 3});
+    const auto card = data_source.getCard(
+        {"test", 3}, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(card);
     ASSERT_TRUE(*card);
     EXPECT_EQ((*card)->name, "Game card");
 
-    const auto missing = data_source.getCard({"test", 4});
+    const auto missing = data_source.getCard(
+        {"test", 4}, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(missing);
     EXPECT_FALSE(*missing);
 }
@@ -63,13 +66,14 @@ TEST(DataSourceFakeTest, ReturnsRelatedData)
         {makeCard(2, "test", 3, "Game card")},
         {{7, "test", "First series", "Description"}},
         {{2, {7}}},
-        {{{"test", "Test Game", "", 1}, {}}});
+        {{{"test", "Test Game", "", GameVisibility::PUBLIC, 1}, {}}});
 
     const auto memberships = data_source.getCardSeries(2);
     ASSERT_TRUE(memberships);
     EXPECT_EQ(*memberships, std::vector<std::int64_t>({7}));
 
-    const auto games = data_source.getGames();
+    const auto games = data_source.getGames(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(games);
     ASSERT_EQ(games->size(), 1);
     EXPECT_EQ(games->front().short_name, "test");
@@ -83,17 +87,56 @@ TEST(DataSourceFakeTest, SortsGamesDeterministically)
         {},
         {},
         {
-            {{"z", "Same", "", 1}, {}},
-            {{"a", "Same", "", 1}, {}},
+            {{"z", "Same", "", GameVisibility::PUBLIC, 1}, {}},
+            {{"a", "Same", "", GameVisibility::PUBLIC, 1}, {}},
         });
 
-    const auto games = data_source.getGames();
+    const auto games = data_source.getGames(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(games);
     ASSERT_EQ(games->size(), 2);
     EXPECT_EQ((*games)[0].short_name, "a");
     EXPECT_EQ((*games)[1].short_name, "z");
 }
 
+TEST(DataSourceFakeTest, FiltersInternalGameContent)
+{
+    DataSourceFake data_source(
+        {
+            makeCard(1, std::nullopt, 1, "Loose"),
+            makeCard(2, "public", 1, "Public"),
+            makeCard(3, "internal", 1, "Internal"),
+        },
+        {
+            {1, "public", "Public series", ""},
+            {2, "internal", "Internal series", ""},
+        },
+        {},
+        {
+            {{"public", "Public", "", GameVisibility::PUBLIC, 1}, {}},
+            {{"internal", "Internal", "", GameVisibility::INTERNAL, 1},
+             {}},
+        });
+
+    auto cards = data_source.getCards(GameContentScope::PUBLIC_ONLY);
+    ASSERT_TRUE(cards);
+    ASSERT_EQ(cards->size(), 2);
+    EXPECT_EQ((*cards)[0].name, "Loose");
+    EXPECT_EQ((*cards)[1].name, "Public");
+    auto hidden = data_source.getCard(
+        {"internal", 1}, GameContentScope::PUBLIC_ONLY);
+    ASSERT_TRUE(hidden);
+    EXPECT_FALSE(*hidden);
+    auto games = data_source.getGames(GameContentScope::PUBLIC_ONLY);
+    ASSERT_TRUE(games);
+    ASSERT_EQ(games->size(), 1);
+    EXPECT_EQ(games->front().short_name, "public");
+    auto series = data_source.getSeries(GameContentScope::PUBLIC_ONLY);
+    ASSERT_TRUE(series);
+    ASSERT_EQ(series->size(), 1);
+    EXPECT_EQ(series->front().game_short_name, "public");
+}
+
 /// Verify development data cannot accidentally be mutated.
 TEST(DataSourceFakeTest, RejectsMutations)
 {
diff --git a/tests/data_mock.h b/tests/data_mock.h
index 311ce88..1e19b6c 100644
--- a/tests/data_mock.h
+++ b/tests/data_mock.h
@@ -45,6 +45,7 @@ public:
                 (const std::string& short_name,
                  const std::string& display_name,
                  const std::string& description,
+                 GameVisibility visibility,
                  std::int64_t expected_revision), (override));
 
     /// Mock game deletion.
@@ -263,12 +264,13 @@ public:
     MOCK_METHOD(
         (mw::E<std::vector<Card>>),
         getCards,
-        (),
+        (GameContentScope scope),
         (const, override));
 
     /// Mock retrieval of one creator's authored cards.
     MOCK_METHOD((mw::E<std::vector<Card>>), getCardsByCreator,
-                (std::int64_t creator_user_id), (const, override));
+                (std::int64_t creator_user_id, GameContentScope scope),
+                (const, override));
 
     /// Mock retrieval of the current positive-rarity pool.
     MOCK_METHOD((mw::E<std::vector<Card>>), getPoolCards,
@@ -278,21 +280,23 @@ public:
     MOCK_METHOD(
         (mw::E<std::optional<Card>>),
         getCard,
-        (const CardIdentity& identity),
+        (const CardIdentity& identity, GameContentScope scope),
         (const, override));
 
     /// Mock retrieval of all database-defined games.
     MOCK_METHOD((mw::E<std::vector<Game>>), getGames,
-                (), (const, override));
+                (GameContentScope scope), (const, override));
 
     /// Mock retrieval of one complete game definition.
     MOCK_METHOD((mw::E<std::optional<GameDefinitionSnapshot>>),
-                getGameDefinition, (const std::string& short_name),
+                getGameDefinition, (const std::string& short_name,
+                                    GameContentScope scope),
                 (const, override));
 
     /// Mock retrieval of a card's definition and custom values.
     MOCK_METHOD((mw::E<std::optional<CardGameFields>>),
-                getCardFieldValues, (std::int64_t card_id),
+                getCardFieldValues, (std::int64_t card_id,
+                                     GameContentScope scope),
                 (const, override));
 
     /// Mock retrieval of a user by internal identity.
@@ -320,7 +324,8 @@ public:
 
     /// Mock retrieval of one user's distinct collection.
     MOCK_METHOD((mw::E<std::vector<CollectionEntry>>), getCollection,
-                (std::int64_t user_id), (const, override));
+                (std::int64_t user_id, GameContentScope scope),
+                (const, override));
 
     /// Mock a persisted card-ownership query.
     MOCK_METHOD((mw::E<bool>), userOwnsCard,
@@ -341,14 +346,14 @@ public:
     MOCK_METHOD(
         (mw::E<std::vector<Series>>),
         getSeries,
-        (),
+        (GameContentScope scope),
         (const, override));
 
     /// Mock retrieval of one series by internal ID.
     MOCK_METHOD(
         (mw::E<std::optional<Series>>),
         getSeries,
-        (std::int64_t series_id),
+        (std::int64_t series_id, GameContentScope scope),
         (const, override));
 
     /// Mock retrieval of a card's series memberships.
diff --git a/tests/data_sqlite_test.cpp b/tests/data_sqlite_test.cpp
index eca1044..6a4c6af 100644
--- a/tests/data_sqlite_test.cpp
+++ b/tests/data_sqlite_test.cpp
@@ -80,9 +80,12 @@ TEST(DataSourceSQLiteTest, RejectsReadsBeforeMigration)
     std::unique_ptr<DataSourceSQLite> data_source =
         std::move(*data_source_result);
 
-    EXPECT_FALSE(data_source->getSeries());
-    EXPECT_FALSE(data_source->getSeries(1));
-    EXPECT_FALSE(data_source->getGames());
+    EXPECT_FALSE(data_source->getSeries(
+        GameContentScope::INCLUDE_INTERNAL));
+    EXPECT_FALSE(data_source->getSeries(
+        1, GameContentScope::INCLUDE_INTERNAL));
+    EXPECT_FALSE(data_source->getGames(
+        GameContentScope::INCLUDE_INTERNAL));
 }
 
 /// Verify startup prepares a fresh database for its first card-index read.
@@ -95,7 +98,8 @@ TEST(DataSourceSQLiteTest, PreparesFreshDatabaseForStartup)
     auto version = (*data_source)->getSchemaVersion();
     ASSERT_TRUE(version);
     EXPECT_EQ(*version, DB_SCHEMA_VERSION);
-    auto cards = (*data_source)->getCards();
+    auto cards = (*data_source)->getCards(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(cards);
     EXPECT_TRUE(cards->empty());
 }
@@ -130,7 +134,8 @@ TEST(DataSourceSQLiteTest, ReturnsCards)
 
     auto data_source = DataSourceSQLite::fromFile(database.path());
     ASSERT_TRUE(data_source);
-    auto cards = (*data_source)->getCards();
+    auto cards = (*data_source)->getCards(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(cards);
     ASSERT_EQ(cards->size(), 2);
 
@@ -159,7 +164,8 @@ TEST(DataSourceSQLiteTest, ReturnsCards)
     EXPECT_EQ((*cards)[1].thumbnail_extension, "avif");
     EXPECT_EQ((*cards)[1].revision, 3);
 
-    auto loose_card = (*data_source)->getCard({std::nullopt, 35});
+    auto loose_card = (*data_source)->getCard(
+        {std::nullopt, 35}, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(loose_card);
     ASSERT_TRUE(*loose_card);
     EXPECT_EQ((**loose_card).id, 1);
@@ -186,7 +192,8 @@ TEST(DataSourceSQLiteTest, RejectsInvalidCards)
 
     auto data_source = DataSourceSQLite::fromFile(database.path());
     ASSERT_TRUE(data_source);
-    EXPECT_FALSE((*data_source)->getCards());
+    EXPECT_FALSE((*data_source)->getCards(
+        GameContentScope::INCLUDE_INTERNAL));
 }
 
 /// Verify a committed transaction persists a complete loose card.
@@ -210,7 +217,8 @@ TEST(DataSourceSQLiteTest, InsertsLooseCard)
     EXPECT_TRUE(*exists_after);
     ASSERT_TRUE((*transaction)->commit());
 
-    auto cards = (*data_source)->getCards();
+    auto cards = (*data_source)->getCards(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(cards);
     ASSERT_EQ(cards->size(), 1);
     EXPECT_EQ(cards->front().id, *card_id);
@@ -244,7 +252,8 @@ TEST(DataSourceSQLiteTest, UpdatesLooseCard)
         **card, {}, {}));
     ASSERT_TRUE((*transaction)->commit());
 
-    auto stored = (*data_source)->getCard({std::nullopt, 42});
+    auto stored = (*data_source)->getCard(
+        {std::nullopt, 42}, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(stored);
     ASSERT_TRUE(*stored);
     EXPECT_EQ((**stored).id, *card_id);
@@ -265,17 +274,19 @@ TEST(DataSourceSQLiteTest, MutatesSeries)
     auto transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
     ASSERT_TRUE((*transaction)->insertGame(
-        {"gh", "Going Home", "", 1}));
+        {"gh", "Going Home", "", GameVisibility::PUBLIC, 1}));
     auto series_id = (*transaction)->insertSeries(
         {0, "gh", "First series", "Description"});
     ASSERT_TRUE(series_id);
     ASSERT_TRUE((*transaction)->commit());
 
-    auto all_series = (*data_source)->getSeries();
+    auto all_series = (*data_source)->getSeries(
+        GameContentScope::INCLUDE_INTERNAL);
     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);
+    auto series = (*data_source)->getSeries(
+        *series_id, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(series);
     ASSERT_TRUE(*series);
     (**series).name = "Edited series";
@@ -284,7 +295,8 @@ TEST(DataSourceSQLiteTest, MutatesSeries)
     ASSERT_TRUE(transaction);
     ASSERT_TRUE((*transaction)->updateSeries(**series));
     ASSERT_TRUE((*transaction)->commit());
-    series = (*data_source)->getSeries(*series_id);
+    series = (*data_source)->getSeries(
+        *series_id, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(series);
     ASSERT_TRUE(*series);
     EXPECT_EQ((**series).name, "Edited series");
@@ -293,11 +305,119 @@ TEST(DataSourceSQLiteTest, MutatesSeries)
     ASSERT_TRUE(transaction);
     ASSERT_TRUE((*transaction)->deleteSeries(*series_id));
     ASSERT_TRUE((*transaction)->commit());
-    series = (*data_source)->getSeries(*series_id);
+    series = (*data_source)->getSeries(
+        *series_id, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(series);
     EXPECT_FALSE(*series);
 }
 
+TEST(DataSourceSQLiteTest, ScopesInternalGameContentAndPulls)
+{
+    TemporaryDatabase database;
+    auto data_source = prepareDataSource(database.path());
+    ASSERT_TRUE(data_source) << data_source.error().msg();
+
+    auto transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->insertGame(
+        {"public", "Public", "", GameVisibility::PUBLIC, 1}));
+    ASSERT_TRUE((*transaction)->insertGame(
+        {"internal", "Internal", "", GameVisibility::INTERNAL, 1}));
+    auto public_series = (*transaction)->insertSeries(
+        {0, "public", "Public series", ""});
+    auto internal_series = (*transaction)->insertSeries(
+        {0, "internal", "Internal series", ""});
+    ASSERT_TRUE(public_series);
+    ASSERT_TRUE(internal_series);
+    auto public_number = (*transaction)->allocateGameNumber("public");
+    auto internal_number = (*transaction)->allocateGameNumber("internal");
+    ASSERT_TRUE(public_number);
+    ASSERT_TRUE(internal_number);
+    Card public_card = makeLooseCard(*public_number, "Public card");
+    public_card.identity.game_short_name = "public";
+    public_card.rarity = 1;
+    Card internal_card = makeLooseCard(*internal_number, "Internal card");
+    internal_card.identity.game_short_name = "internal";
+    internal_card.rarity = 1;
+    auto public_id = (*transaction)->insertCard(
+        public_card, {}, {*public_series});
+    auto internal_id = (*transaction)->insertCard(
+        internal_card, {}, {*internal_series});
+    ASSERT_TRUE(public_id);
+    ASSERT_TRUE(internal_id);
+    ASSERT_TRUE((*transaction)->incrementHolding(1, *public_id));
+    ASSERT_TRUE((*transaction)->incrementHolding(1, *internal_id));
+    ASSERT_TRUE((*transaction)->commit());
+
+    auto public_games = (*data_source)->getGames(
+        GameContentScope::PUBLIC_ONLY);
+    auto all_games = (*data_source)->getGames(
+        GameContentScope::INCLUDE_INTERNAL);
+    ASSERT_TRUE(public_games);
+    ASSERT_TRUE(all_games);
+    ASSERT_EQ(public_games->size(), 1);
+    ASSERT_EQ(all_games->size(), 2);
+    EXPECT_EQ(public_games->front().short_name, "public");
+    EXPECT_EQ((*all_games)[0].visibility, GameVisibility::INTERNAL);
+    EXPECT_EQ((*all_games)[1].visibility, GameVisibility::PUBLIC);
+
+    auto public_cards = (*data_source)->getCards(
+        GameContentScope::PUBLIC_ONLY);
+    auto all_cards = (*data_source)->getCards(
+        GameContentScope::INCLUDE_INTERNAL);
+    ASSERT_TRUE(public_cards);
+    ASSERT_TRUE(all_cards);
+    ASSERT_EQ(public_cards->size(), 1);
+    ASSERT_EQ(all_cards->size(), 2);
+    EXPECT_EQ(public_cards->front().name, "Public card");
+    auto hidden_card = (*data_source)->getCard(
+        {"internal", *internal_number}, GameContentScope::PUBLIC_ONLY);
+    ASSERT_TRUE(hidden_card);
+    EXPECT_FALSE(*hidden_card);
+    auto hidden_definition = (*data_source)->getGameDefinition(
+        "internal", GameContentScope::PUBLIC_ONLY);
+    ASSERT_TRUE(hidden_definition);
+    EXPECT_FALSE(*hidden_definition);
+    auto hidden_fields = (*data_source)->getCardFieldValues(
+        *internal_id, GameContentScope::PUBLIC_ONLY);
+    ASSERT_TRUE(hidden_fields);
+    EXPECT_FALSE(*hidden_fields);
+
+    auto public_series_list = (*data_source)->getSeries(
+        GameContentScope::PUBLIC_ONLY);
+    auto public_collection = (*data_source)->getCollection(
+        1, GameContentScope::PUBLIC_ONLY);
+    auto pool = (*data_source)->getPoolCards();
+    ASSERT_TRUE(public_series_list);
+    ASSERT_TRUE(public_collection);
+    ASSERT_TRUE(pool);
+    EXPECT_EQ(public_series_list->size(), 1);
+    EXPECT_EQ(public_collection->size(), 1);
+    EXPECT_EQ(pool->size(), 1);
+
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    auto locked_pool = (*transaction)->getPoolCardsForUpdate();
+    ASSERT_TRUE(locked_pool);
+    EXPECT_EQ(locked_pool->size(), 1);
+    auto visible = (*transaction)->updateGame(
+        "internal", "Internal", "", GameVisibility::PUBLIC, 1);
+    ASSERT_TRUE(visible);
+    ASSERT_TRUE(*visible);
+    ASSERT_TRUE((*transaction)->commit());
+
+    public_cards = (*data_source)->getCards(GameContentScope::PUBLIC_ONLY);
+    public_collection = (*data_source)->getCollection(
+        1, GameContentScope::PUBLIC_ONLY);
+    pool = (*data_source)->getPoolCards();
+    ASSERT_TRUE(public_cards);
+    ASSERT_TRUE(public_collection);
+    ASSERT_TRUE(pool);
+    EXPECT_EQ(public_cards->size(), 2);
+    EXPECT_EQ(public_collection->size(), 2);
+    EXPECT_EQ(pool->size(), 2);
+}
+
 /// Verify dynamic fields, numbering, values, and memberships persist.
 TEST(DataSourceSQLiteTest, PersistsDynamicGameCards)
 {
@@ -308,7 +428,8 @@ TEST(DataSourceSQLiteTest, PersistsDynamicGameCards)
     auto transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
     ASSERT_TRUE((*transaction)->insertGame(
-        {"test", "Test Game", "Description", 1}));
+        {"test", "Test Game", "Description",
+         GameVisibility::PUBLIC, 1}));
     auto hp_id = (*transaction)->insertGameField(
         {0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
     auto note_id = (*transaction)->insertGameField(
@@ -336,10 +457,12 @@ TEST(DataSourceSQLiteTest, PersistsDynamicGameCards)
     ASSERT_TRUE(card_id);
     ASSERT_TRUE((*transaction)->commit());
 
-    auto stored = (*data_source)->getCard({"test", 1});
+    auto stored = (*data_source)->getCard(
+        {"test", 1}, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(stored);
     ASSERT_TRUE(*stored);
-    auto values = (*data_source)->getCardFieldValues(*card_id);
+    auto values = (*data_source)->getCardFieldValues(
+        *card_id, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(values);
     ASSERT_TRUE(*values);
     ASSERT_EQ((**values).definition.fields.size(), 3);
@@ -357,7 +480,8 @@ TEST(DataSourceSQLiteTest, PersistsDynamicGameCards)
         {{*hp_id, GameFieldType::INTEGER, std::int64_t{70}}},
         {}));
     ASSERT_TRUE((*transaction)->commit());
-    values = (*data_source)->getCardFieldValues(*card_id);
+    values = (*data_source)->getCardFieldValues(
+        *card_id, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(values);
     ASSERT_TRUE(*values);
     ASSERT_EQ((**values).values.size(), 1);
@@ -370,7 +494,8 @@ TEST(DataSourceSQLiteTest, PersistsDynamicGameCards)
         {});
     ASSERT_TRUE(later_id);
     ASSERT_TRUE((*transaction)->commit());
-    values = (*data_source)->getCardFieldValues(*card_id);
+    values = (*data_source)->getCardFieldValues(
+        *card_id, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(values);
     ASSERT_TRUE(*values);
     EXPECT_EQ((**values).definition.fields.size(), 4);
@@ -390,7 +515,8 @@ TEST(DataSourceSQLiteTest, RollsBackLooseCard)
 
     transaction->reset();
 
-    auto cards = (*data_source)->getCards();
+    auto cards = (*data_source)->getCards(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(cards);
     EXPECT_TRUE(cards->empty());
 }
@@ -404,7 +530,7 @@ TEST(DataSourceSQLiteTest, AllocatesGameNumbers)
     auto transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
     ASSERT_TRUE((*transaction)->insertGame(
-        {"gh", "Going Home", "", 1}));
+        {"gh", "Going Home", "", GameVisibility::PUBLIC, 1}));
     auto first = (*transaction)->allocateGameNumber("gh");
     auto second = (*transaction)->allocateGameNumber("gh");
     ASSERT_TRUE(first);
@@ -595,7 +721,8 @@ TEST(DataSourceSQLiteTest, PersistsMvpAccountAndCollectionState)
     EXPECT_EQ(*quantity, 2);
     ASSERT_TRUE((*transaction)->commit());
 
-    auto collection = (*data_source)->getCollection(*player_id);
+    auto collection = (*data_source)->getCollection(
+        *player_id, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(collection);
     ASSERT_EQ(collection->size(), 1);
     EXPECT_EQ(collection->front().quantity, 2);
@@ -603,7 +730,8 @@ TEST(DataSourceSQLiteTest, PersistsMvpAccountAndCollectionState)
     ASSERT_TRUE(transaction);
     ASSERT_TRUE((*transaction)->deleteCard(*card_id));
     ASSERT_TRUE((*transaction)->commit());
-    collection = (*data_source)->getCollection(*player_id);
+    collection = (*data_source)->getCollection(
+        *player_id, GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(collection);
     EXPECT_TRUE(collection->empty());
 }
@@ -627,6 +755,47 @@ TEST(DataSourceSQLiteTest, RejectsObsoletePrototypeSchema)
               std::string::npos);
 }
 
+TEST(DataSourceSQLiteTest, RejectsVersionOneWithoutVisibility)
+{
+    TemporaryDatabase database;
+    auto connection = mw::SQLite::connectFile(database.path().string());
+    ASSERT_TRUE(connection);
+    ASSERT_TRUE((*connection)->execute(
+        "CREATE TABLE users(id INTEGER PRIMARY KEY);"));
+    ASSERT_TRUE((*connection)->execute(
+        "CREATE TABLE games(short_name TEXT PRIMARY KEY);"));
+    ASSERT_TRUE((*connection)->execute("PRAGMA user_version = 1;"));
+    connection->reset();
+
+    auto data_source = DataSourceSQLite::fromFile(database.path());
+    ASSERT_TRUE(data_source);
+    auto version = (*data_source)->getSchemaVersion();
+    ASSERT_FALSE(version);
+    EXPECT_NE(version.error().msg().find("delete and recreate"),
+              std::string::npos);
+
+    data_source->reset();
+    connection = mw::SQLite::connectFile(database.path().string());
+    ASSERT_TRUE(connection);
+    auto columns = (*connection)->evalToValue<std::int64_t>(
+        "SELECT COUNT(*) FROM pragma_table_info('games');");
+    ASSERT_TRUE(columns);
+    EXPECT_EQ(*columns, 1);
+}
+
+TEST(DataSourceSQLiteTest, RejectsInvalidGameVisibility)
+{
+    TemporaryDatabase database;
+    auto data_source = prepareDataSource(database.path());
+    ASSERT_TRUE(data_source) << data_source.error().msg();
+    auto transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+
+    EXPECT_FALSE((*transaction)->insertGame({
+        "invalid", "Invalid", "",
+        static_cast<GameVisibility>(2), 1}));
+}
+
 /// Verify fresh initialization and reopening do not create any games.
 TEST(DataSourceSQLiteTest, StartsAndReopensWithoutGames)
 {
@@ -634,14 +803,16 @@ TEST(DataSourceSQLiteTest, StartsAndReopensWithoutGames)
     auto data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source) << data_source.error().msg();
 
-    auto games = (*data_source)->getGames();
+    auto games = (*data_source)->getGames(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(games);
     EXPECT_TRUE(games->empty());
     data_source->reset();
 
     data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source) << data_source.error().msg();
-    games = (*data_source)->getGames();
+    games = (*data_source)->getGames(
+        GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(games);
     EXPECT_TRUE(games->empty());
 }
@@ -714,7 +885,7 @@ TEST(DataSourceSQLiteTest, RejectsMissingGameSequence)
     auto transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
     ASSERT_TRUE((*transaction)->insertGame(
-        {"test", "Test Game", "", 1}));
+        {"test", "Test Game", "", GameVisibility::PUBLIC, 1}));
     ASSERT_TRUE((*transaction)->commit());
     data_source->reset();
 
@@ -739,9 +910,9 @@ TEST(DataSourceSQLiteTest, EnforcesDynamicFieldInvariants)
     auto transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
     ASSERT_TRUE((*transaction)->insertGame(
-        {"one", "One", "", 1}));
+        {"one", "One", "", GameVisibility::PUBLIC, 1}));
     ASSERT_TRUE((*transaction)->insertGame(
-        {"two", "Two", "", 1}));
+        {"two", "Two", "", GameVisibility::PUBLIC, 1}));
     auto integer_id = (*transaction)->insertGameField(
         {0, "one", "score", "Score", GameFieldType::INTEGER, 0, {}},
         {});
@@ -802,7 +973,7 @@ TEST(DataSourceSQLiteTest, PreservesDynamicIdentitiesAndCascadesValues)
     auto transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
     ASSERT_TRUE((*transaction)->insertGame(
-        {"fixed", "Fixed", "", 1}));
+        {"fixed", "Fixed", "", GameVisibility::PUBLIC, 1}));
     auto field_id = (*transaction)->insertGameField(
         {0, "fixed", "score", "Score", GameFieldType::INTEGER, 0, {}},
         {});
diff --git a/tests/game_field_test.cpp b/tests/game_field_test.cpp
index 8b08b69..1dc6cc8 100644
--- a/tests/game_field_test.cpp
+++ b/tests/game_field_test.cpp
@@ -13,7 +13,7 @@ namespace
 GameDefinitionSnapshot definition()
 {
     return {
-        {"test", "Test", "", 1},
+        {"test", "Test", "", GameVisibility::PUBLIC, 1},
         {
             {1, "test", "score", "Score", GameFieldType::INTEGER, 0, {}},
             {2, "test", "note", "Note", GameFieldType::STRING, 1, {}},
diff --git a/tests/game_service_test.cpp b/tests/game_service_test.cpp
index 189fb4a..8ae7618 100644
--- a/tests/game_service_test.cpp
+++ b/tests/game_service_test.cpp
@@ -115,7 +115,8 @@ TEST(GameServiceTest, MutatesDefinitionsAndRejectsStaleRevisions)
     GameService service(*fixture->data_source);
 
     auto created = service.createGame(
-        fixture->administrator_id, "demo", " Demo Game ", "A **game**.");
+        fixture->administrator_id, "demo", " Demo Game ", "A **game**.",
+        GameVisibility::PUBLIC);
     ASSERT_TRUE(created) << created.error().msg();
     EXPECT_EQ(*created, "demo");
 
@@ -160,7 +161,8 @@ TEST(GameServiceTest, MutatesDefinitionsAndRejectsStaleRevisions)
     EXPECT_EQ(httpError(duplicate_choices.error())->code, 409);
 
     auto stale = service.updateGame(
-        fixture->administrator_id, "demo", 3, "Stale", "");
+        fixture->administrator_id, "demo", 3, "Stale", "",
+        GameVisibility::PUBLIC);
     ASSERT_FALSE(stale);
     ASSERT_NE(httpError(stale.error()), nullptr);
     EXPECT_EQ(httpError(stale.error())->code, 409);
@@ -182,13 +184,20 @@ TEST(GameServiceTest, MutatesDefinitionsAndRejectsStaleRevisions)
         "demo",
         6,
         "Renamed Game",
-        "New description"));
+        "New description",
+        GameVisibility::INTERNAL));
 
-    auto definition = fixture->data_source->getGameDefinition("demo");
+    auto definition = fixture->data_source->getGameDefinition(
+        "demo", GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(definition);
     ASSERT_TRUE(*definition);
     EXPECT_EQ((**definition).game.display_name, "Renamed Game");
+    EXPECT_EQ((**definition).game.visibility, GameVisibility::INTERNAL);
     EXPECT_EQ((**definition).game.revision, 7);
+    auto public_definition = fixture->data_source->getGameDefinition(
+        "demo", GameContentScope::PUBLIC_ONLY);
+    ASSERT_TRUE(public_definition);
+    EXPECT_FALSE(*public_definition);
     ASSERT_EQ((**definition).fields.size(), 3);
     EXPECT_EQ((**definition).fields[0].id, *choice_id);
     EXPECT_EQ((**definition).fields[0].label, "Path");
@@ -206,13 +215,15 @@ TEST(GameServiceTest, EnforcesAuthorizationAndDeletionRules)
     GameService service(*fixture->data_source);
 
     auto forbidden = service.createGame(
-        fixture->player_id, "player", "Player Game", "");
+        fixture->player_id, "player", "Player Game", "",
+        GameVisibility::PUBLIC);
     ASSERT_FALSE(forbidden);
     ASSERT_NE(httpError(forbidden.error()), nullptr);
     EXPECT_EQ(httpError(forbidden.error())->code, 403);
 
     auto invalid = service.createGame(
-        fixture->administrator_id, "d\xc3\xa9mo", "Invalid", "");
+        fixture->administrator_id, "d\xc3\xa9mo", "Invalid", "",
+        GameVisibility::PUBLIC);
     ASSERT_FALSE(invalid);
     const auto* validation =
         invalid.error().as<GameDefinitionValidationError>();
@@ -220,7 +231,8 @@ TEST(GameServiceTest, EnforcesAuthorizationAndDeletionRules)
     EXPECT_EQ(validation->field_name, "short_name");
 
     ASSERT_TRUE(service.createGame(
-        fixture->administrator_id, "used", "Used Game", ""));
+        fixture->administrator_id, "used", "Used Game", "",
+        GameVisibility::PUBLIC));
     auto choice_id = service.createField(
         fixture->administrator_id,
         "used",
@@ -292,10 +304,12 @@ TEST(GameServiceTest, EnforcesAuthorizationAndDeletionRules)
     EXPECT_EQ(httpError(issued_game.error())->code, 409);
 
     ASSERT_TRUE(service.createGame(
-        fixture->administrator_id, "empty", "Empty Game", ""));
+        fixture->administrator_id, "empty", "Empty Game", "",
+        GameVisibility::PUBLIC));
     ASSERT_TRUE(service.removeGame(
         fixture->administrator_id, "empty", 1));
-    auto removed = fixture->data_source->getGameDefinition("empty");
+    auto removed = fixture->data_source->getGameDefinition(
+        "empty", GameContentScope::INCLUDE_INTERNAL);
     ASSERT_TRUE(removed);
     EXPECT_FALSE(*removed);
 }
diff --git a/tests/mvp_primitives_test.cpp b/tests/mvp_primitives_test.cpp
index 9e11fc0..2be5725 100644
--- a/tests/mvp_primitives_test.cpp
+++ b/tests/mvp_primitives_test.cpp
@@ -95,6 +95,31 @@ TEST(AuthorizationTest, EnforcesOwnershipAndRoleExceptions)
     EXPECT_TRUE(authorization.canSetRarity(administrator));
 }
 
+TEST(AuthorizationTest, AppliesInternalGameVisibility)
+{
+    AuthorizationService authorization;
+    const User player = user(1, UserRole::PLAYER);
+    const User creator = user(2, UserRole::CREATOR);
+    const User administrator = user(3, UserRole::ADMINISTRATOR);
+    const Game public_game = {
+        "public", "Public", "", GameVisibility::PUBLIC, 1};
+    const Game internal_game = {
+        "internal", "Internal", "", GameVisibility::INTERNAL, 1};
+
+    EXPECT_EQ(
+        authorization.gameContentScope(player),
+        GameContentScope::PUBLIC_ONLY);
+    EXPECT_EQ(
+        authorization.gameContentScope(creator),
+        GameContentScope::PUBLIC_ONLY);
+    EXPECT_EQ(
+        authorization.gameContentScope(administrator),
+        GameContentScope::INCLUDE_INTERNAL);
+    EXPECT_TRUE(authorization.canUseGame(creator, public_game));
+    EXPECT_FALSE(authorization.canUseGame(creator, internal_game));
+    EXPECT_TRUE(authorization.canUseGame(administrator, internal_game));
+}
+
 TEST(CardPoolTest, CalculatesRequiredRarityRatio)
 {
     CardPoolService pool;