Skip to main content

Tutorial: build the Shop screen

In this tutorial you build a Shop page from nothing: it lists the offers, lets the player pick a quantity and buys. You will use four things:

Before you start: the SDK is installed and Guest login reaches the Lobby (Install and set up), and the Shop scene has a scope derived from ProjectVAuthenticatedSceneScope (Scene integration).

1. Register the pageโ€‹

The page is a MonoBehaviour in the Shop scene. Register it in the scene scope so it can receive the screen contract:

public sealed class ShopSceneScope : ProjectVAuthenticatedSceneScope
{
protected override void ConfigureAuthenticated(IContainerBuilder builder)
{
builder.RegisterComponentInHierarchy<ShopPage>();
}
}

2. Receive the screen contractโ€‹

Add an [Inject] method. The SDK passes the one IProjectVShopScreen of the app; keep it in a field.

using System;
using SilverTiger.ProjectV.Unity.Presentation.Shop;
using UnityEngine;
using VContainer;

public sealed class ShopPage : MonoBehaviour
{
private IProjectVShopScreen shop = null!;

[Inject]
public void Construct(IProjectVShopScreen screen)
{
shop = screen ?? throw new ArgumentNullException(nameof(screen));
}
}

3. Observe while the page is openโ€‹

Observe(render) calls render right away with the current view, and again each time the offers change. It returns an IDisposable: keep it, and dispose it when the page closes so the SDK stops calling a page that is gone.

private IDisposable? observation;

private void OnEnable()
{
observation = shop.Observe(Render);
}

private void OnDisable()
{
observation?.Dispose();
observation = null;
}

You do not load anything yourself: if the offers are not loaded yet, observing loads them.

4. Draw loading and errorsโ€‹

The first call may come before the offers arrive. Then view.IsLoading is true. If that first load failed, view.LoadError says why.

[SerializeField] private GameObject loadingPanel = null!;
[SerializeField] private TMPro.TMP_Text errorText = null!;

private void Render(ProjectVShopView view)
{
loadingPanel.SetActive(view.IsLoading && view.LoadError == null);
errorText.gameObject.SetActive(view.LoadError != null);
errorText.text = view.LoadError ?? string.Empty;
if (view.IsLoading)
{
return;
}

DrawOffers(view);
}

For a Retry button, disable and re-enable the page: OnEnable observes again, which loads again.

5. Draw the offersโ€‹

view.Offers is the list to show, in server order. Each item is a ProjectVShopOfferView. Map OfferId.Value to your own art and names; the SDK only sends IDs and numbers.

[SerializeField] private ShopItem itemPrefab = null!;
[SerializeField] private Transform list = null!;

private void DrawOffers(ProjectVShopView view)
{
foreach (Transform child in list)
{
Destroy(child.gameObject);
}

foreach (var offer in view.Offers)
{
if (offer.IsIap || (offer.LimitKind == ProjectVShopLimitKind.Forever && offer.PurchaseLimitReached))
{
continue; // store offers are not sold yet; one-time offers disappear once bought
}

var item = Instantiate(itemPrefab, list);
item.Show(
offerId: offer.OfferId.Value,
price: $"{offer.WalletPrice} {offer.WalletCurrencyId}",
limit: offer.LimitKind == ProjectVShopLimitKind.None ? "" : $"{offer.PurchaseCount}/{offer.LimitCount}",
soldOut: offer.PurchaseLimitReached,
onBuy: quantity => Buy(offer.OfferId.Value, quantity));
}
}

Render runs again after every purchase with new PurchaseCount values, so the list stays correct without any extra code.

6. Pick a quantityโ€‹

The quantity picker for one offer goes from QuantityMin to the smallest of: QuantityMax, what is left of the limit, and what the player can afford.

private static long MaxQuantity(ProjectVShopOfferView offer, long balance)
{
var max = offer.QuantityMax;
if (offer.LimitKind != ProjectVShopLimitKind.None)
{
max = Math.Min(max, offer.LimitCount - offer.PurchaseCount);
}
if (offer.WalletPrice > 0 && offer.WalletCurrencyId.Length > 0)
{
max = Math.Min(max, balance / offer.WalletPrice);
}
return max; // disable Buy when max < offer.QuantityMin
}

balance is the player's amount of WalletCurrencyId, read from the Header (ProjectVHeaderSnapshot.Currencies, matching CurrencyId.Value). These limits only shape the picker; the server checks every purchase anyway.

7. Buyโ€‹

Call PurchaseAsync(offerId, quantity). It returns a ProjectVResult:

  • IsSuccess true: done. Do nothing else. Render is called with the new purchase count, and the Header shows the new balance.
  • IsSuccess false: show Message; it is written for the player.
private async void Buy(string offerId, int quantity)
{
var result = await shop.PurchaseAsync(offerId, quantity);

// The page may have closed while the purchase ran.
if (this == null || !isActiveAndEnabled)
{
return;
}
if (!result.IsSuccess)
{
ShowPopup(result.Message);
}
}

You do not need to disable the Buy button: a second tap while a purchase is running gets the same result instead of buying twice.

Full codeโ€‹

The three files of this tutorial, complete. ShopItem is a simple row prefab: a price label, a limit label, a quantity label with โˆ’ and + buttons, and a Buy button.

ShopSceneScope.cs
using SilverTiger.ProjectV.Unity.Presentation;
using UnityEngine;
using UnityEngine.SceneManagement;
using VContainer;
using VContainer.Unity;

public sealed class ShopSceneScope : ProjectVAuthenticatedSceneScope
{
protected override void ConfigureAuthenticated(IContainerBuilder builder)
{
builder.RegisterComponentInHierarchy<ShopPage>();
}

protected override void DisableScenePresentation()
{
var page = FindFirstObjectByType<ShopPage>(FindObjectsInactive.Include);
if (page != null)
{
page.enabled = false;
}
}

protected override void NavigateToLogin(string sceneName)
{
SceneManager.LoadScene(sceneName);
}
}
ShopItem.cs
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public sealed class ShopItem : MonoBehaviour
{
[SerializeField] private TMP_Text priceText = null!;
[SerializeField] private TMP_Text limitText = null!;
[SerializeField] private TMP_Text quantityText = null!;
[SerializeField] private Button minusButton = null!;
[SerializeField] private Button plusButton = null!;
[SerializeField] private Button buyButton = null!;

private int quantity = 1;

public void Show(string offerId, string price, string limit, bool soldOut, Action<int> onBuy)
{
name = offerId;
priceText.text = price;
limitText.text = limit;
buyButton.interactable = !soldOut;
quantityText.text = quantity.ToString();

minusButton.onClick.AddListener(() => SetQuantity(quantity - 1));
plusButton.onClick.AddListener(() => SetQuantity(quantity + 1));
buyButton.onClick.AddListener(() => onBuy(quantity));
}

private void SetQuantity(int value)
{
quantity = Math.Max(1, value);
quantityText.text = quantity.ToString();
}
}
ShopPage.cs
using System;
using SilverTiger.ProjectV.Unity.Presentation.Shop;
using UnityEngine;
using VContainer;

public sealed class ShopPage : MonoBehaviour
{
[SerializeField] private GameObject loadingPanel = null!;
[SerializeField] private TMPro.TMP_Text errorText = null!;
[SerializeField] private ShopItem itemPrefab = null!;
[SerializeField] private Transform list = null!;

private IProjectVShopScreen shop = null!;
private IDisposable? observation;

[Inject]
public void Construct(IProjectVShopScreen screen)
{
shop = screen ?? throw new ArgumentNullException(nameof(screen));
}

private void OnEnable()
{
observation = shop.Observe(Render);
}

private void OnDisable()
{
observation?.Dispose();
observation = null;
}

private void Render(ProjectVShopView view)
{
loadingPanel.SetActive(view.IsLoading && view.LoadError == null);
errorText.gameObject.SetActive(view.LoadError != null);
errorText.text = view.LoadError ?? string.Empty;
if (view.IsLoading)
{
return;
}

DrawOffers(view);
}

private void DrawOffers(ProjectVShopView view)
{
foreach (Transform child in list)
{
Destroy(child.gameObject);
}

foreach (var offer in view.Offers)
{
if (offer.IsIap || (offer.LimitKind == ProjectVShopLimitKind.Forever && offer.PurchaseLimitReached))
{
continue; // store offers are not sold yet; one-time offers disappear once bought
}

var item = Instantiate(itemPrefab, list);
item.Show(
offerId: offer.OfferId.Value,
price: $"{offer.WalletPrice} {offer.WalletCurrencyId}",
limit: offer.LimitKind == ProjectVShopLimitKind.None ? "" : $"{offer.PurchaseCount}/{offer.LimitCount}",
soldOut: offer.PurchaseLimitReached,
onBuy: quantity => Buy(offer.OfferId.Value, quantity));
}
}

private static long MaxQuantity(ProjectVShopOfferView offer, long balance)
{
var max = offer.QuantityMax;
if (offer.LimitKind != ProjectVShopLimitKind.None)
{
max = Math.Min(max, offer.LimitCount - offer.PurchaseCount);
}
if (offer.WalletPrice > 0 && offer.WalletCurrencyId.Length > 0)
{
max = Math.Min(max, balance / offer.WalletPrice);
}
return max; // disable Buy when max < offer.QuantityMin
}

private async void Buy(string offerId, int quantity)
{
var result = await shop.PurchaseAsync(offerId, quantity);

// The page may have closed while the purchase ran.
if (this == null || !isActiveAndEnabled)
{
return;
}
if (!result.IsSuccess)
{
ShowPopup(result.Message);
}
}

private void ShowPopup(string message)
{
errorText.gameObject.SetActive(true);
errorText.text = message;
}
}

8. Try itโ€‹

Enter Play mode from the Login scene, sign in as Guest and open the Shop:

  • the offers appear (a spinner shows briefly the first time);
  • buying an offer you can afford succeeds, the Header balance drops and the purchase count goes up;
  • buying more than you can afford shows the server's message;
  • a fast double tap buys only once;
  • closing the page during a purchase gives no error in the Console.

What's nextโ€‹