Tutorial: build the Login flow
In this tutorial you build the Login scene from nothing: it waits for the server check, shows the sign-in buttons, signs the player in and opens the Lobby. Last, you add a sign-out button that brings the player back. You will use five things:
IProjectVStartupPresenter: starts the game and reports each step;ProjectVStartupSnapshot: the step to draw;IProjectVAuthenticationPresenter: the sign-in commands and state;IProjectVGuestIdentityProvider: this device's Guest ID;IProjectVAccountPresenter: sign-out from the Lobby.
Before you start: the SDK is installed and its root is in the Login scene
(Install and set up,
One root in the Login scene), and the
Lobby scene has a scope derived from ProjectVAuthenticatedSceneScope
(Scene integration).
The previous tutorial, Build the Shop screen, shows the
observe-and-dispose pattern used again here.
1. Register the page
The page is a MonoBehaviour in the Login scene. Derive the scene scope from
ProjectVLoginSceneScope
and register the page in ConfigureLogin:
public sealed class LoginSceneScope : ProjectVLoginSceneScope
{
protected override void ConfigureLogin(IContainerBuilder builder)
{
builder.RegisterComponentInHierarchy<LoginPage>();
}
}
2. Receive the presenters
Login has no screen contract: the page uses the presenters directly. Ask for
the three of them in an [Inject] method and keep them in fields.
[Inject]
public void Construct(
IProjectVStartupPresenter startupPresenter,
IProjectVAuthenticationPresenter authenticationPresenter,
IProjectVGuestIdentityProvider guestIdentityProvider)
{
startup = startupPresenter ?? throw new ArgumentNullException(nameof(startupPresenter));
authentication = authenticationPresenter ?? throw new ArgumentNullException(nameof(authenticationPresenter));
guestIdentity = guestIdentityProvider ?? throw new ArgumentNullException(nameof(guestIdentityProvider));
}
3. Subscribe while the page is open
SubscribeAsync(listener) calls the listener right away with the current
state, then on every change. It returns an IDisposable: dispose it when the
page closes. The listener may run off the Unity main thread, so it only
starts a method that switches to the main thread first.
private void OnStartupChanged(ProjectVStartupSnapshot snapshot) => RenderStartupAsync(snapshot).Forget();
private async UniTaskVoid RenderStartupAsync(ProjectVStartupSnapshot snapshot)
{
await UniTask.SwitchToMainThread();
if (this == null)
{
return;
}
// Draw the step (step 5).
}
Do the same for the Authentication presenter with OnAuthenticationChanged.
In OnDisable, dispose both subscriptions and cancel a
CancellationTokenSource that you pass to every command.
4. Start startup when the content is ready
Call StartAsync(contentReady: true, token) once your own content
(Addressables, localization) has loaded. Until then startup waits in
WaitingForContent.
private async void OnEnable()
{
lifetime = new CancellationTokenSource();
var token = lifetime.Token;
var startupSub = await startup.SubscribeAsync(OnStartupChanged);
var authenticationSub = await authentication.SubscribeAsync(OnAuthenticationChanged);
if (token.IsCancellationRequested)
{
// The page closed while subscribing.
startupSub.Dispose();
authenticationSub.Dispose();
return;
}
startupSubscription = startupSub;
authenticationSubscription = authenticationSub;
await LoadContentAsync(token);
if (!token.IsCancellationRequested)
{
await startup.StartAsync(contentReady: true, token);
}
}
You do not need the result: the listener sees every step. The token only stops your wait; startup itself keeps running.
5. Draw each startup step
Pick the panel from the snapshot: IsReady opens the Lobby,
RequiresLogin (AwaitingLogin) shows the sign-in buttons, IsBlocking
shows a blocking screen, and anything else shows the loading screen.
if (snapshot.IsReady)
{
OpenLobby();
return;
}
loadingPanel.SetActive(!snapshot.RequiresLogin && !snapshot.IsBlocking);
loginPanel.SetActive(snapshot.RequiresLogin);
blockingPanel.SetActive(snapshot.IsBlocking);
loadingText.text = snapshot.Status == ProjectVStartupStatus.Admitting ? "Connecting…" : "Loading…";
if (snapshot.IsBlocking)
{
ShowBlocking(snapshot);
}
The blocking statuses are Maintenance, UpdateRequired,
ContractMismatch, Offline and Failed. Show one message per status, and
a Retry button that calls StartAsync(true, token) again. Hide Retry for
UpdateRequired and ContractMismatch: the player must update the game.
Put Error.RequestId, when set, on the screen for support. Never open the
Lobby past a blocking status.
In a real game, take the text from your localization table with
snapshot.Error.LocalizationKey (projectv.startup.<category>) when Error
is set.
6. Sign in as Guest
Get this device's Guest ID from IProjectVGuestIdentityProvider, then call
AuthenticateDeviceAsync. Pass null for the options. Always use the
provider's ID, or the player gets a new Guest account.
public async void OnGuestClicked()
{
if (lifetime == null)
{
return;
}
var token = lifetime.Token;
try
{
var id = await guestIdentity.GetOrCreateAsync(token);
var result = await authentication.AuthenticateDeviceAsync(id, null, token);
await ContinueAfterSignInAsync(result, token);
}
catch (OperationCanceledException)
{
// The page closed.
}
}
The authentication listener disables the buttons while IsBusy and shows
Error when a sign-in fails. A Cancelled error means the player closed a
prompt or the page closed: show nothing.
7. Add the Google button
Show the Google button only when the SDK says Google works here. Decide from
GetNativeAvailability, never from the Unity platform.
googleButton.gameObject.SetActive(
authentication.GetNativeAvailability(ProjectVNativeAuthenticationProvider.Google).IsAvailable);
Its click handler is the Guest handler with
authentication.SignInWithGoogleAsync(null, token) in place of the two
Guest calls. Google also needs its setup asset; see
Google and Apple.
8. Hand over to startup
A successful sign-in does not load the player's data. Check
result.Snapshot.IsAuthenticated (not IsSuccess), then call StartAsync
again. The startup listener walks through Hydrating to Ready, and step 5
opens the Lobby.
private async Task ContinueAfterSignInAsync(ProjectVAuthenticationCommandResult result, CancellationToken token)
{
if (this == null || !result.Snapshot.IsAuthenticated)
{
return; // the authentication listener shows the error
}
await startup.StartAsync(contentReady: true, token);
}
Never open the Lobby straight after the sign-in: wait for Ready.
9. Sign out from the Lobby
Add an AccountPopup to the Lobby scene and register it in the Lobby scope's
ConfigureAuthenticated:
builder.RegisterComponentInHierarchy<AccountPopup>();
Its Logout button calls IProjectVAccountPresenter.LogoutAsync(token). The
SDK signs the player out and clears the account's data, then sends one
snapshot with RequiresLoginNavigation true. Load Login from the listener
when it comes:
if (snapshot.RequiresLoginNavigation)
{
OpenLogin();
return;
}
Back on Login, OnEnable calls StartAsync again; with no saved sign-in it
ends on AwaitingLogin and shows the buttons.
Full code
using SilverTiger.ProjectV.Unity.Presentation;
using VContainer;
using VContainer.Unity;
public sealed class LoginSceneScope : ProjectVLoginSceneScope
{
protected override void ConfigureLogin(IContainerBuilder builder)
{
builder.RegisterComponentInHierarchy<LoginPage>();
}
}
using System;
using System.Threading;
using System.Threading.Tasks;
using Cysharp.Threading.Tasks;
using SilverTiger.ProjectV.Unity.Presentation;
using SilverTiger.ProjectV.Unity.Presentation.Authentication;
using SilverTiger.ProjectV.Unity.Presentation.Startup;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using VContainer;
public sealed class LoginPage : MonoBehaviour
{
private const string LobbyScene = "Lobby";
[SerializeField] private GameObject loadingPanel = null!;
[SerializeField] private TMP_Text loadingText = null!;
[SerializeField] private GameObject loginPanel = null!;
[SerializeField] private Button guestButton = null!;
[SerializeField] private Button googleButton = null!;
[SerializeField] private TMP_Text loginErrorText = null!;
[SerializeField] private GameObject blockingPanel = null!;
[SerializeField] private TMP_Text blockingText = null!;
[SerializeField] private Button retryButton = null!;
private IProjectVStartupPresenter startup = null!;
private IProjectVAuthenticationPresenter authentication = null!;
private IProjectVGuestIdentityProvider guestIdentity = null!;
private IDisposable? startupSubscription;
private IDisposable? authenticationSubscription;
private CancellationTokenSource? lifetime;
private bool lobbyRequested;
[Inject]
public void Construct(
IProjectVStartupPresenter startupPresenter,
IProjectVAuthenticationPresenter authenticationPresenter,
IProjectVGuestIdentityProvider guestIdentityProvider)
{
startup = startupPresenter ?? throw new ArgumentNullException(nameof(startupPresenter));
authentication = authenticationPresenter ?? throw new ArgumentNullException(nameof(authenticationPresenter));
guestIdentity = guestIdentityProvider ?? throw new ArgumentNullException(nameof(guestIdentityProvider));
}
private async void OnEnable()
{
lifetime = new CancellationTokenSource();
var token = lifetime.Token;
googleButton.gameObject.SetActive(
authentication.GetNativeAvailability(ProjectVNativeAuthenticationProvider.Google).IsAvailable);
var startupSub = await startup.SubscribeAsync(OnStartupChanged);
var authenticationSub = await authentication.SubscribeAsync(OnAuthenticationChanged);
if (token.IsCancellationRequested)
{
// The page closed while subscribing.
startupSub.Dispose();
authenticationSub.Dispose();
return;
}
startupSubscription = startupSub;
authenticationSubscription = authenticationSub;
await LoadContentAsync(token);
if (!token.IsCancellationRequested)
{
await startup.StartAsync(contentReady: true, token);
}
}
private void OnDisable()
{
startupSubscription?.Dispose();
startupSubscription = null;
authenticationSubscription?.Dispose();
authenticationSubscription = null;
lifetime?.Cancel();
lifetime?.Dispose();
lifetime = null;
}
// Load your Addressables, localization and so on here.
private static Task LoadContentAsync(CancellationToken token)
{
return Task.CompletedTask;
}
// May be called off the Unity main thread.
private void OnStartupChanged(ProjectVStartupSnapshot snapshot) => RenderStartupAsync(snapshot).Forget();
private async UniTaskVoid RenderStartupAsync(ProjectVStartupSnapshot snapshot)
{
await UniTask.SwitchToMainThread();
if (this == null)
{
return;
}
if (snapshot.IsReady)
{
OpenLobby();
return;
}
loadingPanel.SetActive(!snapshot.RequiresLogin && !snapshot.IsBlocking);
loginPanel.SetActive(snapshot.RequiresLogin);
blockingPanel.SetActive(snapshot.IsBlocking);
loadingText.text = snapshot.Status == ProjectVStartupStatus.Admitting ? "Connecting…" : "Loading…";
if (snapshot.IsBlocking)
{
ShowBlocking(snapshot);
}
}
private void ShowBlocking(ProjectVStartupSnapshot snapshot)
{
var message = snapshot.Status switch
{
ProjectVStartupStatus.Maintenance => "The server is under maintenance. Please try again later.",
ProjectVStartupStatus.UpdateRequired => "A new version is available. Please update the game from the store.",
ProjectVStartupStatus.ContractMismatch => "Please update the game to keep playing.",
ProjectVStartupStatus.Offline => "No connection. Check your network and try again.",
_ => "Something went wrong. Please try again.",
};
var requestId = snapshot.Error?.RequestId;
blockingText.text = requestId == null ? message : $"{message}\nSupport code: {requestId}";
// Retrying cannot help until the player updates the game.
var canRetry = snapshot.Status != ProjectVStartupStatus.UpdateRequired &&
snapshot.Status != ProjectVStartupStatus.ContractMismatch;
retryButton.gameObject.SetActive(canRetry);
}
// Retry button on the blocking screen.
public async void OnRetryClicked()
{
if (lifetime == null)
{
return;
}
await startup.StartAsync(contentReady: true, lifetime.Token);
}
// May be called off the Unity main thread.
private void OnAuthenticationChanged(ProjectVAuthenticationSnapshot snapshot) => RenderAuthenticationAsync(snapshot).Forget();
private async UniTaskVoid RenderAuthenticationAsync(ProjectVAuthenticationSnapshot snapshot)
{
await UniTask.SwitchToMainThread();
if (this == null)
{
return;
}
guestButton.interactable = !snapshot.IsBusy;
googleButton.interactable = !snapshot.IsBusy;
// Cancelled: the player closed a prompt or the page closed.
var error = snapshot.Error;
var showError = error != null && error.Category != ProjectVBackendErrorCategory.Cancelled;
loginErrorText.gameObject.SetActive(showError);
loginErrorText.text = showError ? SignInMessage(error!) : string.Empty;
}
private static string SignInMessage(ProjectVAuthenticationError error)
{
if (error.Category == ProjectVBackendErrorCategory.Network ||
error.Category == ProjectVBackendErrorCategory.Timeout)
{
return "No connection. Please try again.";
}
return "Sign-in failed. Please try again.";
}
public async void OnGuestClicked()
{
if (lifetime == null)
{
return;
}
var token = lifetime.Token;
try
{
var id = await guestIdentity.GetOrCreateAsync(token);
var result = await authentication.AuthenticateDeviceAsync(id, null, token);
await ContinueAfterSignInAsync(result, token);
}
catch (OperationCanceledException)
{
// The page closed.
}
}
public async void OnGoogleClicked()
{
if (lifetime == null)
{
return;
}
var token = lifetime.Token;
try
{
var result = await authentication.SignInWithGoogleAsync(null, token);
await ContinueAfterSignInAsync(result, token);
}
catch (OperationCanceledException)
{
// The page closed.
}
}
private async Task ContinueAfterSignInAsync(ProjectVAuthenticationCommandResult result, CancellationToken token)
{
if (this == null || !result.Snapshot.IsAuthenticated)
{
return; // the authentication listener shows the error
}
// The startup listener opens the Lobby on Ready.
await startup.StartAsync(contentReady: true, token);
}
private void OpenLobby()
{
if (lobbyRequested)
{
return;
}
lobbyRequested = true;
SceneManager.LoadScene(LobbyScene);
}
}
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using SilverTiger.ProjectV.Unity.Presentation;
using SilverTiger.ProjectV.Unity.Presentation.Account;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using VContainer;
public sealed class AccountPopup : MonoBehaviour
{
private const string LoginScene = "Login";
[SerializeField] private Button logoutButton = null!;
[SerializeField] private TMP_Text errorText = null!;
private IProjectVAccountPresenter account = null!;
private IDisposable? subscription;
private CancellationTokenSource? lifetime;
private bool loginRequested;
[Inject]
public void Construct(IProjectVAccountPresenter presenter)
{
account = presenter ?? throw new ArgumentNullException(nameof(presenter));
}
private async void OnEnable()
{
lifetime = new CancellationTokenSource();
var token = lifetime.Token;
var accountSub = await account.SubscribeAsync(OnAccountChanged);
if (token.IsCancellationRequested)
{
// The popup closed while subscribing.
accountSub.Dispose();
return;
}
subscription = accountSub;
}
private void OnDisable()
{
subscription?.Dispose();
subscription = null;
lifetime?.Cancel();
lifetime?.Dispose();
lifetime = null;
}
// May be called off the Unity main thread.
private void OnAccountChanged(ProjectVAccountSnapshot snapshot) => RenderAsync(snapshot).Forget();
private async UniTaskVoid RenderAsync(ProjectVAccountSnapshot snapshot)
{
await UniTask.SwitchToMainThread();
if (this == null)
{
return;
}
// Sent once, right after the sign-out.
if (snapshot.RequiresLoginNavigation)
{
OpenLogin();
return;
}
logoutButton.interactable = !IsBusy(snapshot.Status);
var error = snapshot.Error;
var showError = error != null && error.Category != ProjectVBackendErrorCategory.Cancelled;
errorText.gameObject.SetActive(showError);
errorText.text = showError ? "Could not sign out. Please try again." : string.Empty;
}
public async void OnLogoutClicked()
{
if (lifetime == null)
{
return;
}
// The listener opens Login, or shows the error.
await account.LogoutAsync(lifetime.Token);
}
private static bool IsBusy(ProjectVAccountStatus status)
{
return status == ProjectVAccountStatus.RequestingDeletion ||
status == ProjectVAccountStatus.ConfirmingDeletion ||
status == ProjectVAccountStatus.ShuttingDown;
}
private void OpenLogin()
{
if (loginRequested)
{
return;
}
loginRequested = true;
SceneManager.LoadScene(LoginScene);
}
}
In the Inspector, set each button's On Click to the matching public method:
OnGuestClicked, OnGoogleClicked, OnRetryClicked and OnLogoutClicked.
Try it
Clear the game's saved data, then enter Play mode from the Login scene:
- "Connecting…" shows briefly, then the sign-in buttons;
- the Google button shows only where Google sign-in is set up;
- Guest sign-in disables the buttons, shows the loading screen and opens the Lobby;
- stopping and entering Play mode again opens the Lobby without the buttons (the sign-in was kept);
- Logout in the account popup opens Login with the sign-in buttons;
- Guest sign-in after logout returns to the same Guest account;
- with the network off, the no-connection screen shows with Retry, and Retry works once the network is back;
- closing the scene during a sign-in gives no error in the Console.
What's next
- The Startup page lists every status and why startup stops.
- The Authentication page covers username and password, Apple, and every sign-in failure.
- The Account page adds account deletion to the same popup.
- Next: Tutorial: Level up a character.