┌──────────────────────────────────────────────────────────────────┐
│ GAME CLIENT │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Presentation Layer (UI Screens) │ │
│ │ ├─ CardBackStoreScreen │ │
│ │ ├─ CardBackUploadScreen │ │
│ │ └─ SettingsScreen (Card Back selection) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ↓ Uses │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Business Logic Layer (CardBackManager) │ │
│ │ ├─ Inventory Management (CRUD) │ │
│ │ ├─ Custom Photo Processing (resize, validate) │ │
│ │ └─ Selection & Persistence │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ↓ Uses │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Service Abstraction Layer (ICardBackStore) │ │
│ │ Enables backend-agnostic code │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ↓ Implemented By (Dependency Injection) │
│ ┌──────────────────┬──────────────────┬──────────────────┐ │
│ │ AzureCardBack │ FirebaseCardBack │ LocalOnlyCardBack│ │
│ │ Store │ Store │ Store │ │
│ └──────────────────┴──────────────────┴──────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Local Storage │ │
│ │ ├─ CardBacksInventory.json │ │
│ │ ├─ CustomCardBacks/ │ │
│ │ │ ├─ custom_uuid_1.png │ │
│ │ │ ├─ metadata.json │ │
│ │ │ └─ ... │ │
│ │ └─ CachedStoreData/ │ │
│ │ ├─ CardBackStore.json │ │
│ │ └─ Categories.json │ │
│ └─────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
│
│ (Only on Store Access)
│ HTTP/HTTPS
↓
┌──────────────────────────────────────────────────────────────────┐
│ CLOUD BACKEND (Azure/Firebase) │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Static Content Delivery (CDN) │ │
│ │ ├─ CardBackStore.json (catalog) │ │
│ │ ├─ Categories.json (metadata) │ │
│ │ └─ Official Card Back Images │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ IAP Validation (API) │ │
│ │ ├─ Validate Apple Store receipts │ │
│ │ ├─ Validate Google Play receipts │ │
│ │ └─ Record ownership (optional database) │ │
│ └─────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
User Opens Store
↓
CardBackStoreScreen.LoadContent()
↓
CardBackManager.GetAvailableCardBacks()
↓
ICardBackStore.GetAvailableCardBacksAsync()
├─ Try: Fetch from cloud (CardBackStore.json)
├─ Catch (Network Error): Use cached version
└─ Parse & merge with local inventory
↓
Display store UI with:
├─ Downloaded official card backs
├─ Already owned items (marked)
├─ Already owned custom photos
└─ Available slots for new custom photos
↓
User Purchases Card Back
├─ Platform IAP (StoreKit2 / Google Play)
├─ Receive receipt
├─ Send to backend for validation
├─ Backend confirms ownership
└─ Download card back image
↓
User Uploads Custom Photo
├─ Select photo from camera roll
├─ Resize to card dimensions (256x384)
├─ Compress as PNG
├─ Save to CustomCardBacks/
└─ Update CardBacksInventory.json
Location:
%AppData%/CartBlanche/Blackjack/CardBacksInventory.json~/Library/Application Support/CartBlanche/Blackjack/CardBacksInventory.json/sdcard/Android/data/com.yourcompany.blackjack/files/CardBacksInventory.json~/Documents/CartBlanche/Blackjack/CardBacksInventory.jsonStructure:
{
"version": 1,
"platform": "Windows",
"lastUpdated": "2024-01-20T10:30:00Z",
"ownedCardBacks": [
{
"id": "cardback_flags_usa",
"name": "🇺🇸 USA Flag",
"type": "official",
"category": "flags",
"purchaseDate": "2024-01-15T10:30:00Z",
"price": 0.99,
"currency": "USD"
},
{
"id": "cardback_sports_nfl_patriots",
"name": "🏈 New England Patriots",
"type": "official",
"category": "sports",
"purchaseDate": "2024-01-15T10:35:00Z",
"price": 0.99,
"currency": "USD"
}
],
"selectedCardBack": "cardback_flags_usa",
"customCardBacks": [
{
"id": "custom_550e8400-e29b-41d4-a716-446655440000",
"name": "My Photo",
"filename": "custom_550e8400.png",
"createdDate": "2024-01-20T14:22:00Z",
"imageSize": 45382,
"originalWidth": 1024,
"originalHeight": 1536,
"resizedWidth": 256,
"resizedHeight": 384
}
],
"customPhotoCount": 1,
"maxCustomPhotos": 3
}
Purpose: Index all custom photos with checksums for integrity checking
{
"version": 1,
"customBacks": [
{
"id": "custom_550e8400-e29b-41d4-a716-446655440000",
"filename": "custom_550e8400.png",
"originalSize": {
"width": 1024,
"height": 1536
},
"resizedTo": {
"width": 256,
"height": 384
},
"fileSizeBytes": 45382,
"uploadedDate": "2024-01-20T14:22:00Z",
"sha256Checksum": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z"
}
]
}
Location: Azure Blob Storage / Firebase Cloud Storage
CDN URL: https://cardbacks.azureedge.net/store/CardBackStore.json (or Firebase equivalent)
Updated: Quarterly (manually)
{
"version": 2,
"lastUpdated": "2024-01-20T10:00:00Z",
"releaseNotes": "Q1 2024: Added 10 new country flags and 5 new sports teams",
"basePrice": {
"USD": 0.99,
"EUR": 0.89,
"GBP": 0.79,
"JPY": 110,
"CAD": 1.29,
"AUD": 1.49
},
"cardBacks": [
{
"id": "cardback_flags_usa",
"name": "🇺🇸 USA Flag",
"description": "Stars and stripes",
"category": "flags",
"subcategory": "north_america",
"rarity": "common",
"price": {
"USD": 0.99,
"EUR": 0.89,
"GBP": 0.79
},
"images": {
"thumbnail": "https://cardbacks.azureedge.net/official/cardback_flags_usa/thumbnail.jpg",
"preview": "https://cardbacks.azureedge.net/official/cardback_flags_usa/preview.jpg",
"full": "https://cardbacks.azureedge.net/official/cardback_flags_usa/full.png"
},
"isAvailable": true,
"releaseDate": "2024-01-01T00:00:00Z",
"region": "all",
"tags": ["flag", "patriotic", "usa"]
},
{
"id": "cardback_sports_nfl_patriots",
"name": "🏈 New England Patriots",
"description": "NFL team logo",
"category": "sports",
"subcategory": "nfl",
"rarity": "common",
"price": {
"USD": 0.99
},
"images": {
"thumbnail": "https://cardbacks.azureedge.net/official/cardback_sports_nfl_patriots/thumbnail.jpg",
"preview": "https://cardbacks.azureedge.net/official/cardback_sports_nfl_patriots/preview.jpg",
"full": "https://cardbacks.azureedge.net/official/cardback_sports_nfl_patriots/full.png"
},
"isAvailable": true,
"releaseDate": "2024-01-15T00:00:00Z",
"region": "all",
"tags": ["nfl", "sports", "football"]
}
],
"categories": [
{
"id": "flags",
"name": "Country Flags",
"icon": "https://cardbacks.azureedge.net/categories/flags.png",
"description": "Represent your nation",
"itemCount": 195,
"subcategories": [
{ "id": "north_america", "name": "North America" },
{ "id": "south_america", "name": "South America" },
{ "id": "europe", "name": "Europe" },
{ "id": "asia", "name": "Asia" },
{ "id": "africa", "name": "Africa" },
{ "id": "oceania", "name": "Oceania" }
]
},
{
"id": "sports",
"name": "Sports Teams",
"icon": "https://cardbacks.azureedge.net/categories/sports.png",
"description": "Support your favorite teams",
"itemCount": 150,
"subcategories": [
{ "id": "nfl", "name": "NFL", "teams": 32 },
{ "id": "nhl", "name": "NHL", "teams": 32 },
{ "id": "premier_league", "name": "Premier League", "teams": 20 },
{ "id": "serie_a", "name": "Serie A", "teams": 20 },
{ "id": "ligue_1", "name": "Ligue 1", "teams": 20 },
{ "id": "afl", "name": "AFL", "teams": 18 },
{ "id": "other_european", "name": "Other European", "teams": 8 }
]
}
]
}
Lightweight version for faster loading
{
"version": 1,
"lastUpdated": "2024-01-20T10:00:00Z",
"categories": [
{
"id": "flags",
"name": "Country Flags",
"icon": "https://cardbacks.azureedge.net/categories/flags.png",
"itemCount": 195,
"subcategories": [
{ "id": "north_america", "name": "North America", "count": 23 },
{ "id": "south_america", "name": "South America", "count": 12 },
{ "id": "europe", "name": "Europe", "count": 44 },
{ "id": "asia", "name": "Asia", "count": 48 },
{ "id": "africa", "name": "Africa", "count": 54 },
{ "id": "oceania", "name": "Oceania", "count": 14 }
]
},
{
"id": "sports",
"name": "Sports Teams",
"icon": "https://cardbacks.azureedge.net/categories/sports.png",
"itemCount": 150,
"subcategories": [
{ "id": "nfl", "name": "NFL", "count": 32 },
{ "id": "nhl", "name": "NHL", "count": 32 },
{ "id": "premier_league", "name": "Premier League", "count": 20 },
{ "id": "serie_a", "name": "Serie A", "count": 20 },
{ "id": "ligue_1", "name": "Ligue 1", "count": 20 },
{ "id": "afl", "name": "AFL", "count": 18 },
{ "id": "other_european", "name": "Other European", "count": 8 }
]
}
]
}
All card backs: $0.99 USD (converted to local currency)
Conversion rates (example):
Total Flags: 195 (all UN-recognized nations + 3 territories)
Organized by Region:
All 54 African nation flags in standard order (Algeria, Angola, Benin, Botswana, Burkina Faso, Burundi, Cameroon, Cape Verde, Central African Republic, Chad, Comoros, Congo, Democratic Republic of the Congo, Djibouti, Egypt, Equatorial Guinea, Eritrea, Eswatini, Ethiopia, Gabon, Gambia, Ghana, Guinea, Guinea-Bissau, Ivory Coast, Kenya, Lesotho, Liberia, Libya, Madagascar, Malawi, Mali, Mauritania, Mauritius, Morocco, Mozambique, Namibia, Niger, Nigeria, Rwanda, Sao Tome and Principe, Senegal, Seychelles, Sierra Leone, Somalia, South Africa, South Sudan, Sudan, Togo, Tunisia, Uganda, Zambia, Zimbabwe)
AFC East (4 teams)
AFC North (4 teams)
AFC South (4 teams)
AFC West (4 teams)
NFC East (4 teams)
NFC North (4 teams)
NFC South (4 teams)
NFC West (4 teams)
Atlantic Division (8 teams)
Metropolitan Division (8 teams)
Central Division (8 teams)
Pacific Division (8 teams)
La Liga (Spain)
Bundesliga (Germany)
Eredivisie (Netherlands)
Primeira Liga (Portugal)
Goal: Full offline-capable system without cloud dependency
Files to Create:
Core/Game/CardBackSystem/CardBackManager.cs - Core business logicCore/Game/CardBackSystem/CardBackSerializer.cs - File I/OCore/Game/CardBackSystem/CustomPhotoProcessor.cs - Image resize/validateCore/Game/Screens/CardBackStoreScreen.cs - Browse/download UICore/Game/Screens/CardBackUploadScreen.cs - Photo upload UIFiles to Modify:
Core/Game/Misc/GameSettings.cs - Add SelectedCardBackIdCore/Game/Screens/SettingsScreen.cs - Add card back management optionDeliverables:
Testing:
Goal: Add cloud store catalog with fallback offline support
Infrastructure Setup:
Files to Create:
Core/Game/CardBackSystem/ICardBackStore.cs - Service interfaceCore/Game/CardBackSystem/AzureCardBackStore.cs - Azure implementationCore/Game/CardBackSystem/CardBackStoreClient.cs - HTTP clientCore/Game/IAP/IAPManager.cs - Platform IAP wrapperCore/Game/Network/AzureIAPValidator.cs - Receipt validationConfiguration:
Create appsettings.json or environment variables:
{
"CardBackStore": {
"Provider": "Azure",
"AzureStorageUrl": "https://cardbacks.azureedge.net/",
"CatalogPath": "store/CardBackStore.json",
"ImageCachePath": "CachedCardBacks/",
"CacheDurationHours": 24
},
"IAP": {
"ValidationEndpoint": "https://your-function.azurewebsites.net/api/validateIAP"
}
}
Deliverables:
Testing:
Goal: Provide Firebase implementation without modifying game code
Files to Create:
Core/Game/CardBackSystem/FirebaseCardBackStore.cs - Firebase implementationCore/Game/CardBackSystem/FirebaseIAPValidator.cs - Firebase Cloud FunctionsFirebase Setup:
Configuration:
{
"CardBackStore": {
"Provider": "Firebase",
"FirebaseProjectId": "your-project-id",
"FirebaseStorageBucket": "your-project.appspot.com"
}
}
Deliverables:
Testing:
Optional Enhancements:
public interface ICardBackAnalytics
{
Task RecordCardBackViewedAsync(string cardBackId);
Task RecordCardBackPurchasedAsync(string cardBackId);
Task RecordCustomPhotoUploadedAsync();
}
Track:
public async Task<bool> GiftCardBackAsync(string cardBackId, string recipientGamerId)
{
// Send gift notification
// Record gift in database
// Grant ownership to recipient
}
{
"activePromotion": {
"name": "Summer 2024",
"discount": 0.50,
"applicableCategories": ["sports"],
"startDate": "2024-06-01",
"endDate": "2024-08-31"
}
}
Allow players to rate card backs (5-star system)
Goal: Add Steam support for Windows/Linux/macOS distribution with hybrid IAP handling
Why Add Steam?
Infrastructure Setup:
Files to Create:
Core/Game/CardBackSystem/SteamCardBackStore.cs - Steam implementationCore/Game/CardBackSystem/HybridCardBackStore.cs - Multi-platform orchestratorCore/Game/Platform/SteamPlatformDetector.cs - Runtime platform detectionCore/Game/IAP/SteamIAPHandler.cs - Steam-specific IAP logicNuGet Dependencies:
<PackageReference Include="Steamworks.NET" Version="20.0.0" />
Configuration:
{
"CardBackStore": {
"Provider": "Hybrid",
"EnableSteam": true,
"SteamAppId": 1234567,
"AzureStorageUrl": "https://cardbacks.azureedge.net/",
"FallbackProvider": "Azure"
},
"IAP": {
"Platforms": ["Steam", "iOS", "Android"],
"ValidationEndpoint": "https://your-function.azurewebsites.net/api/validateIAP"
}
}
Implementation Details:
/// <summary>
/// Multi-platform card back store that routes to appropriate handler based on platform.
/// Supports Steam, iOS (StoreKit2), Android (Google Play), and local-only fallback.
/// </summary>
public class HybridCardBackStore : ICardBackStore
{
private readonly Steamworks.SteamInventory _steamInventory;
private readonly IAPManager _iapManager;
private readonly ICardBackStore _contentStore; // Azure/Firebase for images
private readonly CardBacksInventory _inventory;
private readonly string _currentPlatform;
public HybridCardBackStore(
IAPManager iapManager,
ICardBackStore contentStore,
CardBacksInventory inventory)
{
_iapManager = iapManager;
_contentStore = contentStore;
_inventory = inventory;
_currentPlatform = DetectCurrentPlatform();
// Initialize Steam if available
if (_currentPlatform == "Steam")
{
try
{
Steamworks.SteamClient.Init(GameSettings.Instance.SteamAppId);
}
catch (Exception ex)
{
Debug.WriteLine($"Steam initialization failed: {ex.Message}");
_currentPlatform = "Offline"; // Fallback
}
}
}
public async Task<ServiceResult<List<CardBackInfo>>> GetAvailableCardBacksAsync()
{
// Get catalog from content store (Azure/Firebase)
var contentResult = await _contentStore.GetAvailableCardBacksAsync();
if (!contentResult.Success)
return contentResult;
// Enrich with platform-specific ownership info
foreach (var cardBack in contentResult.Data)
{
cardBack.IsOwned = await CheckOwnershipAsync(cardBack.Id);
cardBack.PlatformPrice = GetPlatformPrice(cardBack);
}
return contentResult;
}
public async Task<ServiceResult<bool>> ValidatePurchaseAsync(
string productId,
string receiptData,
string platform)
{
return platform switch
{
"Steam" => await ValidateSteamPurchaseAsync(productId),
"iOS" => await _iapManager.ValidateAppleReceiptAsync(productId, receiptData),
"Android" => await _iapManager.ValidateGooglePlayReceiptAsync(productId, receiptData),
_ => ServiceResult<bool>.FromError("Unknown platform", ServiceErrorType.Unknown)
};
}
private async Task<ServiceResult<bool>> ValidateSteamPurchaseAsync(string productId)
{
try
{
if (!Steamworks.SteamClient.IsValid)
return ServiceResult<bool>.FromError("Steam not available", ServiceErrorType.ServerError);
// Check if player owns item in Steam inventory
bool ownsSteamItem = _steamInventory.GetItemCount(productId) > 0;
if (!ownsSteamItem)
return ServiceResult<bool>.FromError("Item not owned on Steam", ServiceErrorType.Unauthorized);
// Get card back details from content store
var cardBackResult = await _contentStore.GetCardBackDetailsAsync(productId);
if (!cardBackResult.Success)
return ServiceResult<bool>.FromError("Card back not found", ServiceErrorType.NotFound);
// Record ownership locally
_inventory.OwnedCardBacks.Add(new OwnedCardBackInfo
{
Id = productId,
Name = cardBackResult.Data.Name,
Type = "official",
Category = cardBackResult.Data.Category,
PurchaseDate = DateTime.UtcNow,
Price = 0.99m,
Currency = "USD",
Platform = "Steam"
});
_inventory.SaveToFile();
return ServiceResult<bool>.FromSuccess(true);
}
catch (Exception ex)
{
Debug.WriteLine($"Steam validation error: {ex.Message}");
return ServiceResult<bool>.FromError("Validation failed", ServiceErrorType.ServerError);
}
}
public async Task<ServiceResult<bool>> CheckOwnershipAsync(string cardBackId, string playerGamerId = null)
{
return _currentPlatform switch
{
"Steam" => Task.FromResult(ServiceResult<bool>.FromSuccess(
_steamInventory.GetItemCount(cardBackId) > 0
)).Result,
"iOS" or "Android" => Task.FromResult(ServiceResult<bool>.FromSuccess(
_inventory.OwnedCardBacks.Any(cb => cb.Id == cardBackId)
)).Result,
_ => Task.FromResult(ServiceResult<bool>.FromSuccess(false)).Result
};
}
public bool IsOnline
{
get
{
if (_currentPlatform == "Steam")
return Steamworks.SteamClient.IsValid && Steamworks.SteamClient.IsLoggedOn;
return _contentStore.IsOnline;
}
}
private string DetectCurrentPlatform()
{
// Check if running on Steam
if (Environment.GetEnvironmentVariable("SteamAppId") != null ||
File.Exists("steam_appid.txt"))
return "Steam";
// Check platform OS
if (OperatingSystem.IsIOS())
return "iOS";
if (OperatingSystem.IsAndroid())
return "Android";
// Default to online store
return "Online";
}
private string GetPlatformPrice(CardBackInfo cardBack)
{
return _currentPlatform switch
{
"Steam" => "$0.99 USD", // Or regional Steam price
"iOS" => "$0.99",
"Android" => "$0.99 USD",
_ => cardBack.Price?.USD.ToString("C") ?? "—"
};
}
public event EventHandler<StoreRefreshedEventArgs> StoreRefreshed;
protected virtual void OnStoreRefreshed()
{
StoreRefreshed?.Invoke(this, new StoreRefreshedEventArgs());
}
}
public class CardBackStoreScreen : GameScreen
{
private HybridCardBackStore _store;
private bool _isSteamPlatform;
public override void LoadContent()
{
_store = CardBackStoreFactory.GetStore() as HybridCardBackStore;
_isSteamPlatform = _store.CurrentPlatform == "Steam";
// Adjust UI based on platform
if (_isSteamPlatform)
{
// Hide price if using Steam (Steam displays it via overlay)
ShowSteamPriceWarning();
// Show "Open in Steam" button instead of in-game purchase
ShowSteamCheckout();
}
else
{
// Standard in-app purchase UI
ShowStandardCheckout();
}
}
private async void OnPurchaseClicked(CardBackInfo cardBack)
{
if (_isSteamPlatform)
{
// Redirect to Steam Community Hub or Steam Store
Steamworks.SteamFriends.OpenWebOverlay("https://store.steampowered.com/app/" + SteamAppId);
}
else
{
// Use platform IAP as normal
var result = await _store.ValidatePurchaseAsync(
cardBack.Id,
GetPlatformReceiptData(),
_store.CurrentPlatform
);
if (result.Success)
ShowPurchaseSuccess();
else
ShowError(result.Error);
}
}
}
Deliverables:
Testing:
Steam-Specific Considerations:
steam_appid.txt file needed in game rootEstimated Total: 12 weeks for full multi-platform support
Fast Track (App Store First):
Steam-First Launch (Desktop):
/// <summary>
/// Backend-agnostic interface for card back store operations.
/// Enables swapping between Azure, Firebase, or custom implementations.
/// </summary>
public interface ICardBackStore
{
/// <summary>
/// Get all available card backs from store catalog.
/// Falls back to cached data if network unavailable.
/// </summary>
Task<ServiceResult<List<CardBackInfo>>> GetAvailableCardBacksAsync();
/// <summary>
/// Get detailed information about a specific card back.
/// </summary>
Task<ServiceResult<CardBackInfo>> GetCardBackDetailsAsync(string cardBackId);
/// <summary>
/// Download card back image from cloud storage.
/// Caches locally for offline use.
/// </summary>
Task<ServiceResult<Stream>> DownloadCardBackImageAsync(string cardBackId, ImageSize size);
/// <summary>
/// Validate IAP receipt with platform and grant ownership.
/// </summary>
Task<ServiceResult<bool>> ValidatePurchaseAsync(
string productId,
string receiptData,
string platform); // "iOS" or "Android"
/// <summary>
/// Get catalog categories and metadata.
/// </summary>
Task<ServiceResult<List<Category>>> GetCategoriesAsync();
/// <summary>
/// Check if player owns a specific card back.
/// (Optional - mainly for server-validation in networked games)
/// </summary>
Task<ServiceResult<bool>> CheckOwnershipAsync(string cardBackId, string playerGamerId);
/// <summary>
/// Get current online status (for UI feedback).
/// </summary>
bool IsOnline { get; }
/// <summary>
/// Event fired when store data is refreshed.
/// </summary>
event EventHandler<StoreRefreshedEventArgs> StoreRefreshed;
}
/// <summary>
/// Result wrapper for all service calls.
/// Provides consistent error handling across backends.
/// </summary>
public class ServiceResult<T>
{
public bool Success { get; set; }
public T Data { get; set; }
public string Error { get; set; }
public ServiceErrorType ErrorType { get; set; } // Network, NotFound, Unauthorized, etc.
public DateTime Timestamp { get; set; }
public static ServiceResult<T> FromSuccess(T data) =>
new() { Success = true, Data = data, Timestamp = DateTime.UtcNow };
public static ServiceResult<T> FromError(string error, ServiceErrorType errorType) =>
new() { Success = false, Error = error, ErrorType = errorType, Timestamp = DateTime.UtcNow };
}
public enum ServiceErrorType
{
None,
NetworkUnavailable,
Unauthorized,
NotFound,
InvalidReceipt,
ServerError,
Unknown
}
/// <summary>
/// Factory for creating backend-specific store implementations.
/// Configuration-driven to enable runtime backend switching.
/// </summary>
public static class CardBackStoreFactory
{
private static ICardBackStore _instance;
public static ICardBackStore GetStore()
{
if (_instance == null)
{
var config = GameSettings.Instance.CardBackStoreConfig;
_instance = config.Provider switch
{
BackendProvider.Azure => new AzureCardBackStore(
config.AzureStorageUrl,
config.CatalogPath),
BackendProvider.Firebase => new FirebaseCardBackStore(
config.FirebaseProjectId,
config.FirebaseStorageBucket),
BackendProvider.LocalOnly => new LocalOnlyCardBackStore(),
_ => throw new InvalidOperationException($"Unknown backend: {config.Provider}")
};
// Subscribe to language changes for catalog refresh
LanguageManager.LanguageChanged += (lang) =>
{
_instance?.RefreshCatalogAsync().ConfigureAwait(false);
};
}
return _instance;
}
/// <summary>
/// Swap backend at runtime (for testing or user migration).
/// </summary>
public static void SwitchBackend(BackendProvider newProvider)
{
_instance = null;
GameSettings.Instance.CardBackStoreConfig.Provider = newProvider;
// Force reload next time GetStore() is called
}
}
public enum BackendProvider
{
Azure,
Firebase,
LocalOnly
}
// In your game's initialization (e.g., CardsGame.cs)
public class CardsGame : Game
{
public void Initialize()
{
// Register services
var cardBackStore = CardBackStoreFactory.GetStore();
var cardBackManager = new CardBackManager(cardBackStore);
// Make available to screens
Services.AddSingleton<ICardBackStore>(cardBackStore);
Services.AddSingleton<CardBackManager>(cardBackManager);
// Screens can now inject:
// public CardBackStoreScreen(ICardBackStore store, CardBackManager manager)
}
}
Scenario: You start with Azure but later want to switch to Firebase
Step 1: Implement Firebase service
public class FirebaseCardBackStore : ICardBackStore
{
// Implementation...
}
Step 2: Update configuration
{
"CardBackStore": {
"Provider": "Firebase" // Changed from "Azure"
}
}
Step 3: No game code changes needed!
var store = CardBackStoreFactory.GetStore(); // Returns Firebase instance
// Everything works the same
Risk: Players could manually edit inventory files to add items they don't own
Mitigation:
Log suspicious activity (for future anti-cheat)
private bool ValidateIntegrity()
{
if (_inventory == null) return false;
string json = JsonConvert.SerializeObject(_inventory);
string calculatedHash = ComputeSHA256(json);
if (calculatedHash != _storedHash)
{
Debug.WriteLine("WARNING: Card back inventory tampered with!");
_inventory = CreateDefaultInventory();
return false;
}
return true;
}
Risk: Players claim purchases they didn't make
Mitigation:
Rate-limit validation requests per player
public async Task<ServiceResult<bool>> ValidatePurchaseAsync(
string productId,
string receiptData,
string platform)
{
try
{
// Step 1: Rate limit
if (!RateLimiter.AllowValidation(playerId))
return ServiceResult<bool>.FromError("Rate limited", ServiceErrorType.Unauthorized);
// Step 2: Validate with platform
bool isValid = platform switch
{
"iOS" => await ValidateAppleReceiptAsync(receiptData),
"Android" => await ValidateGooglePlayReceiptAsync(receiptData),
_ => false
};
if (!isValid)
return ServiceResult<bool>.FromError("Invalid receipt", ServiceErrorType.InvalidReceipt);
// Step 3: Record ownership
await recordPurchaseAsync(productId, playerId);
return ServiceResult<bool>.FromSuccess(true);
}
catch (Exception ex)
{
Debug.WriteLine($"IAP validation error: {ex.Message}");
return ServiceResult<bool>.FromError("Validation failed", ServiceErrorType.ServerError);
}
}
Risk: Players upload inappropriate content or exploit with massive files
Mitigation:
Local-only (no server = no moderation needed)
public async Task<(bool Success, Image ResizedImage)> ValidateAndResizePhotoAsync(
string imagePath)
{
try
{
// Validate file size
var fileInfo = new FileInfo(imagePath);
const long MaxSizeBytes = 5 * 1024 * 1024; // 5MB
if (fileInfo.Length > MaxSizeBytes)
return (false, null);
// Load and validate format
using (var image = Image.Load(imagePath))
{
if (!(image is PngImage or JpegImage))
return (false, null);
// Resize to card dimensions
const int CardWidth = 256;
const int CardHeight = 384;
image.Mutate(x => x.Resize(
new ResizeOptions
{
Size = new Size(CardWidth, CardHeight),
Mode = ResizeMode.Max,
Sampler = KnownResamplers.Lanczos3
}));
return (true, image);
}
}
catch
{
return (false, null);
}
}
Risk: Man-in-the-middle attacks, data tampering
Mitigation:
Verify certificate pinning (optional)
// Enforce HTTPS in HTTP client configuration
var handler = new HttpClientHandler();
handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
{
// In production, implement certificate pinning
#if DEBUG
return true; // Allow self-signed certs in development
#else
// Verify against known production certificates
return cert.Thumbprint == KnownProductionThumbprint;
#endif
};
var httpClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(10)
};
Risk: Player data exposed
Mitigation:
Monthly Costs at Different Scales:
| Scale | Storage | CDN | Functions | API | Total |
|---|---|---|---|---|---|
| 1k players | $0.20 | $0.15 | $0.10 | $0.05 | $0.50 |
| 10k players | $2.00 | $1.50 | $1.00 | $0.50 | $5.00 |
| 100k players | $20 | $15 | $10 | $5 | $50 |
| 1M players | $200 | $150 | $100 | $50 | $500 |
Breakdown:
Initial Setup: ~$50-100 (one-time)
Monthly Costs (Firebase free tier is generous):
| Scale | Free Tier Limits | Firestore | Storage | Functions | Total |
|---|---|---|---|---|---|
| < 1k | Included | Free | Free | Free | $0 |
| 10k | Moderate overage | ~$2 | ~$1 | ~$1 | $4 |
| 100k | High overage | ~$20 | ~$10 | ~$10 | $40 |
| 1M | Extensive | ~$200 | ~$100 | ~$100 | $400 |
Comparison:
Cost: $0/month forever Trade-off: No cloud store, manual card back updates via app patches
Monthly Costs:
| Scale | Monthly Revenue | Steam Cut | Net Revenue |
|---|---|---|---|
| 100 sales @ $0.99 | $99 | $30 | $69 |
| 1,000 sales @ $0.99 | $990 | $297 | $693 |
| 10,000 sales @ $0.99 | $9,900 | $2,970 | $6,930 |
Advantages:
Considerations:
Steam provides access to millions of desktop gamers and handles all the complexity of payment processing, regional pricing, and fraud prevention automatically. With the HybridCardBackStore architecture, you can support Steam alongside mobile platforms with a single codebase.
| Aspect | Mobile (iOS/Android) | Steam | Web/Custom |
|---|---|---|---|
| Payment Processing | Apple/Google handles | Steam handles | You build |
| Regional Pricing | Manual per region | Automatic | Manual |
| Fraud Prevention | Handled by platform | Handled by Steam | You build |
| Platform Fee | 30% | 30% | Variable |
| Audience Size | Billions (mobile) | 120M+ active | Varies |
| Offline Support | Yes (local receipts) | Yes (local inventory) | No |
| Setup Effort | Medium | Medium | High |
| Time to Launch | 1-2 weeks review | 1-3 weeks review | Variable |
The HybridCardBackStore you'll implement in Phase 5 means:
✅ Single Codebase: No separate "Steam version" of the game ✅ Runtime Detection: Automatically detects platform and routes correctly ✅ Fallback Support: Works offline if Steam unavailable ✅ Content Sharing: Same card back images used across all platforms ✅ Inventory Sync: Local inventory works on all platforms ✅ No Code Changes: Just swap config to support new platform
Rather than thinking "How do I add Steam support?", think:
This is why we built the abstraction layer first.
Rich Presence: Shows in Discord/Steam what you're doing
Steamworks.SteamFriends.SetRichPresence("status", "Customizing Card Backs");
Achievements: Award achievements for purchases
Steamworks.SteamUserStats.SetAchievement("BoughtFirstCardBack");
Statistics: Track player behavior
Steamworks.SteamUserStats.SetStat("CardBacksPurchased", count);
Screenshots: Players can easily share their cards
Steamworks.SteamScreenshots.TriggerScreenshot();
Workshop (Future): Let players create/share custom designs
Allow community to upload card back designs
Vote on best designs
Revenue sharing with creators
Recommended Timeline: 12 weeks total
Weeks 1-4: Phases 1-2 (Local + Azure)
└─ Playable on mobile/web
Weeks 5-8: Phase 5 (Steam Integration)
└─ Add Steam without touching mobile code
└─ Same IAP manager handles both
Weeks 9-12: Phase 3-4 (Firebase + Analytics)
└─ Optional backend alternatives
└─ Advanced features
public class BlackjackGame : Game
{
private HybridCardBackStore _cardBackStore;
protected override void Initialize()
{
// Initialize ONCE - factory handles platform detection
_cardBackStore = CardBackStoreFactory.GetStore();
// That's it. Everything else works automatically.
base.Initialize();
}
protected override void LoadContent()
{
// Load current card back - works same on all platforms
string selectedId = GameSettings.Instance.SelectedCardBackId;
var texture = _cardBackStore.GetCardBackTexture(selectedId);
// Game doesn't care if it came from Steam, Apple, or local custom
base.LoadContent();
}
public async void OnStoreScreenOpened()
{
// Fetch available card backs - works on all platforms
var result = await _cardBackStore.GetAvailableCardBacksAsync();
// Display store with platform-aware pricing
DisplayStore(result.Data);
}
public async void OnBuyButtonClicked(string cardBackId)
{
// Validate purchase - routes to correct platform handler
var result = await _cardBackStore.ValidatePurchaseAsync(
cardBackId,
GetPlatformReceiptData(),
_cardBackStore.CurrentPlatform
);
// Handle result same way on all platforms
if (result.Success)
GrantCardBack(cardBackId);
else
ShowError(result.Error);
}
}
Option A: Mobile First (Lower Risk)
Option B: Multi-Launch (Faster Growth)
Option C: Steam Exclusive Launch (Desktop Only)
Q: Do I need separate Steam product IDs for each card back?
A: Yes, but you can bulk-create them in Steamworks admin. Each card back gets an ID like cardback_001, cardback_flags_usa, etc.
Q: How do players purchase? A: On Steam, they purchase in the Steam store client. Your game just checks Steam inventory. On mobile, they use native IAP (Apple/Google).
Q: Can I have exclusive card backs for Steam vs mobile?
A: Yes! Use the region or platform field in CardBackStore.json to specify which card backs appear where.
Q: What about regional pricing? A: Steam handles it automatically. You set price in $USD, Steam auto-converts to local currencies (EUR, GBP, JPY, etc.).
Q: Do I need a separate backend for Steam? A: No. Steam IS your backend for payment. You use Azure/Firebase just for card back images and metadata.
Q: Can Steam players trade card backs with each other? A: Not with the current architecture (single-player purchases). You could add this later with Steam Inventory Service.