Skip to content

Commit 12792b8

Browse files
authored
Create guide for logged-out user experience in games
Add comprehensive guide for supporting logged-out players in Devvit games, covering design, user prompts, sharing, and state management.
1 parent 9bd4bfd commit 12792b8

1 file changed

Lines changed: 380 additions & 0 deletions

File tree

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
# Building for Logged Out Players
2+
3+
Reddit has a large base of logged out users who arrive through SEO, shared links, or directly on reddit.com. Your game should support logged out users so that you can reach a larger audience, and you can create a path to convert those users into Reddit accounts who can subscribe to your game and come back to play more.
4+
5+
This guide shows you how to design a Devvit game for logged-out traffic:
6+
7+
- Make your game playable for logged out users: don't gate the core experience behind a login wall.
8+
- Prompt users to log in at the right moment so they can subscribe to or follow your game.
9+
- Optimize sharing so shared links and previews attract new players.
10+
- Save the game state across the login boundary so logged out users who create an account can continue seamlessly after signing up.
11+
12+
## Design for logged out users
13+
14+
![Syllo Play Screen](../assets/analytics/syllo-play-2.png)
15+
16+
### Create "just play" sessions
17+
18+
Logged-out users:
19+
20+
- Often arrive via search or shared links
21+
- Lack context (no comments, subscriptions, or community cues)
22+
23+
Recommended patterns:
24+
25+
- Make the entry point obvious (e.g., a clear "Play" button)
26+
- Provide in-game instructions (don't rely on external UI)
27+
- Don't require login to start gameplay
28+
- Reserve advanced features (saved progress, leaderboards, social) for logged-in users
29+
30+
### Detect user state
31+
32+
Distinguish between logged-in and logged-out users in your app logic. In Devvit, use the context:
33+
34+
- `context.userId` is present only when the user is logged in
35+
- `context.appSlug`, `context.postId`, etc. are available in both cases
36+
37+
```ts
38+
import type { Context } from "@devvit/public-api";
39+
40+
export async function onPlay(context: Context) {
41+
const isLoggedIn = Boolean(context.userId);
42+
43+
if (!isLoggedIn) {
44+
// Logged-out experience: ie, no comments or "run as user" actions
45+
// Keep the focus on playing the level.
46+
} else {
47+
// Enable richer features for logged-in players.
48+
}
49+
}
50+
```
51+
52+
This pattern is especially important once you start using the login effect to gate certain actions (saving progress, sharing, etc.) behind a login flow.
53+
54+
**Analytics and RDF**
55+
56+
Your app's analytics dashboard distinguishes logged-in vs logged-out users.
57+
58+
Note: logged out traffic does not count towards qualified engagement for Reddit Developer Funds.
59+
60+
![Syllo Play Screen](../assets/analytics/engagement-analytics.png)
61+
62+
## Prompt users to log in
63+
64+
The biggest reason to convert a logged-out player into a logged-in user is retention: a logged-in user can subscribe to your subreddit, follow your game, and receive notifications that bring them back. A logged-out player who closes the tab is gone.
65+
66+
Use `showLoginPrompt` to trigger Reddit's login/sign-up flow at a moment you choose. After the user creates or logs into their account, they return to your game.
67+
68+
![Reddit Login Screen](../assets/analytics/login-screen.png)
69+
70+
### When to prompt
71+
72+
Trigger `showLoginPrompt()` during user actions where logging in unlocks something the player already wants, such as:
73+
74+
- Subscribing to your game's subreddit (highest-leverage moment for retention)
75+
- Following your game for updates or notifications
76+
- Saving progress
77+
- Sharing results
78+
- Accessing social features (leaderboards, comments)
79+
80+
### Best practices
81+
82+
#### Only prompt logged-out users
83+
84+
Check if the user is already logged in. If so, don't trigger the login effect.
85+
86+
```ts
87+
import type { Context } from "@devvit/public-api";
88+
import { showLoginPrompt } from "@devvit/client";
89+
90+
export async function onLoginButtonClick(context: Context) {
91+
if (context.userId) {
92+
return;
93+
}
94+
95+
showLoginPrompt();
96+
}
97+
```
98+
99+
####
100+
101+
#### Trigger at natural breakpoints
102+
103+
The login/sign-up flow reloads the page, so any in-memory game state will be lost unless you s[ave the logged-out game state](#save-the-logged-out-game-state).
104+
105+
Recommendations:
106+
107+
- Trigger `showLoginPrompt()` only at natural stopping points, e.g.:
108+
- After a level is completed
109+
- On a results or summary screen
110+
- Before starting a new game
111+
- Avoid prompting:
112+
- Mid-puzzle or in the middle of an action that can't easily be resumed
113+
- Repeatedly on every play; repeated prompts are likely to be ignored
114+
115+
#### Pair the prompt with a clear value proposition
116+
117+
Before triggering the prompt, show in-game messaging that tells the user _why_ they should log in. "Sign in to save game data and subscribe" converts better than a bare login dialog.
118+
119+
:::note
120+
121+
Your CTA must let the user know their game data will be saved when they subscribe.
122+
123+
:::
124+
125+
**Example: gating a "save progress" feature**
126+
127+
```ts
128+
import type { Context } from "@devvit/public-api";
129+
import { showLoginPrompt } from "@devvit/client";
130+
131+
export async function onSaveProgress(context: Context) {
132+
const isLoggedIn = Boolean(context.userId);
133+
134+
if (!isLoggedIn) {
135+
// Optional: show in-game messaging before the prompt.
136+
// e.g. render "Sign in to save your progress" in your own UI.
137+
138+
showLoginPrompt();
139+
return;
140+
}
141+
142+
// User is logged in; proceed with your normal save logic.
143+
await saveProgressForUser(context);
144+
}
145+
```
146+
147+
## Customize sharing to attract new players
148+
149+
Shares are one of the main ways logged-out users arrive at your game. A well-customized share gives the recipient a clear reason to play and a clean landing experience, instead of a generic Reddit preview.
150+
151+
There are three pieces:
152+
153+
1. `showShareSheet` to trigger sharing from inside your app
154+
2. **Deeplinks** to attach up to 1024 characters of data to a shared link
155+
3. **Share previews** to set the image and text shown when links unfurl off-platform
156+
157+
### Trigger sharing from your app
158+
159+
Use `showShareSheet` to invoke Reddit's share behavior from your app.
160+
161+
Example: display an invite banner
162+
163+
![Syllo Sharesheet](../assets/analytics/syllo-sharesheet.png)
164+
165+
```ts
166+
import { showShareSheet } from "@devvit/web/client";
167+
168+
await showShareSheet({
169+
title: "Play today's puzzle", // optional title
170+
text: "I solved the puzzle in 30s. Can you beat my time?", // optional body message
171+
data: JSON.stringify({
172+
// optional share data (≤ 1024 chars)
173+
type: "puzzle_challenge",
174+
from: "userA",
175+
message: "userA challenged you to solve this puzzle",
176+
}),
177+
});
178+
```
179+
180+
Parameters:
181+
182+
- `data?: string`
183+
- Arbitrary payload (invite code, JSON, etc.)
184+
- Must be **≤ 1024 characters**
185+
- Becomes the shared "user data" you can read when the link is opened
186+
- `title?: string`
187+
- Optional title for the share sheet or message
188+
- `text?: string`
189+
- Optional pre-filled message body
190+
- `post?: T3` (on some platforms)
191+
- Optional; if omitted, the current Devvit post is used
192+
193+
You don't need to build your own share URLs for the standard "share this post" flow. `showShareSheet` handles that for you.
194+
195+
### Share data and deeplinks
196+
197+
Attach extra `data` to shared links and read it back when a recipient opens them using `getShareData()`.
198+
199+
**Writing share data**
200+
201+
Best practices:
202+
203+
- Keep it short (≤ 1024 characters)
204+
- Treat share data as untrusted input (users can tamper with links). Do **not** trust share data as an identity or authorization token
205+
- Validate before using the payload
206+
207+
**Reading share data when the page loads**
208+
209+
```ts
210+
import { getShareData } from "@devvit/web/client";
211+
212+
const raw = getShareData();
213+
const shared = raw ? JSON.parse(raw) : undefined;
214+
215+
if (shared?.type === "puzzle_challenge") {
216+
// Render challenge UI for the recipient.
217+
}
218+
```
219+
220+
### Customize share previews
221+
222+
Set custom share images and text for each post instead of showing the default Reddit branding. This image is used for the thumbnail preview in compact subreddit feeds and for off-platform link unfurls.
223+
224+
![Custom Share Image](../assets/analytics/syllo-logo.png)
225+
226+
**Set share image when creating a post**
227+
228+
```ts
229+
// 1. Upload your image to Reddit via the media API.
230+
import { media } from "@devvit/web/server";
231+
232+
async function uploadShareImageUrl(): Promise<string | undefined> {
233+
try {
234+
const uploadResult = await media.upload({
235+
url: "https://example.com/share-preview.png",
236+
type: "image",
237+
});
238+
return uploadResult.mediaUrl;
239+
} catch (error) {
240+
console.error(`Failed to upload share image: ${error}`);
241+
return undefined;
242+
}
243+
}
244+
245+
// 2. Use the returned mediaUrl in submitCustomPost.
246+
const shareImageUrl = await uploadShareImageUrl();
247+
const post = await reddit.submitCustomPost({
248+
title: "Daily Post",
249+
subredditName: subreddit.name,
250+
...(shareImageUrl ? { shareImageUrl } : {}),
251+
});
252+
```
253+
254+
**Update share image for an existing post**
255+
256+
```ts
257+
const post = await context.reddit.getPostById(context.postId);
258+
259+
await post.setShareImageUrl("https://example.com/new-share-image.png");
260+
```
261+
262+
Notes:
263+
264+
- Use a **static image URL** with appropriate resolution and aspect ratio
265+
- The same image is used across major destinations (iMessage, WhatsApp, SMS, Slack, etc.)
266+
- If you don't set a share image, Reddit uses a default app or post-level preview
267+
268+
## Save the logged-out game state
269+
270+
To make this a good experience for logged-out users who convert to logged-in users, you can save their game state across the login boundary. This example shows how to save a simplified completed game state (`score`) using the browser’s localStorage.
271+
272+
:::note
273+
274+
You should only collect and retain data strictly necessary for gameplay engagement and continuity. Game engagement data must not be used for advertising, profiling, personalization outside the gameplay experience, or any unrelated secondary purposes.
275+
276+
:::
277+
278+
Saving game state to localStorage comes with some limitations: localStorage resets whenever you install a new app version, and it does not persist across different browsers.
279+
280+
### Set up localStorage helpers
281+
282+
```javascript
283+
type LoggedOutScore = {
284+
postId: string;
285+
score: number;
286+
};
287+
288+
const loggedOutScoreKey = (postId: string) => `mygame:loggedOutScore:${postId}`;
289+
290+
export function writeLoggedOutScoreToLocalStorage(value: LoggedOutScore): void {
291+
try {
292+
window.localStorage.setItem(
293+
loggedOutScoreKey(value.postId),
294+
JSON.stringify(value)
295+
);
296+
} catch {
297+
// Add error handling
298+
}
299+
}
300+
301+
export function readLoggedOutScore(postId: string): LoggedOutScore | null {
302+
try {
303+
const raw = window.localStorage.getItem(loggedOutScoreKey(postId));
304+
if (!raw) return null;
305+
306+
const parsed: unknown = JSON.parse(raw);
307+
if (
308+
!parsed ||
309+
typeof parsed !== "object" ||
310+
typeof (parsed as LoggedOutScore).postId !== "string" ||
311+
typeof (parsed as PendingScore).score !== "number"
312+
) {
313+
return null;
314+
}
315+
316+
return parsed as LoggedOutScore;
317+
} catch {
318+
return null;
319+
}
320+
}
321+
322+
export function clearLoggedOutScore(postId: string): void {
323+
window.localStorage.removeItem(loggedOutScoreKey(postId));
324+
}
325+
```
326+
327+
### Game ends: save the score from this logged-out game
328+
329+
- If the user is logged in: send the data to Redis using `username` / `userId` from the session.
330+
- If not logged in: `JSON.stringify` the score and `localStorage.setItem` under a key that includes postId (or some other unique identifier, ie `gameId`, depending on your app setup) so different posts don't get mixed up.
331+
332+
```javascript
333+
async function onGameEnd(postId: string, userLoggedIn: boolean, score: number) {
334+
if (userLoggedIn) {
335+
await saveScore(postId, score);
336+
clearLoggedOutScore(postId);
337+
} else {
338+
writeLoggedOutScoreToLocalStorage(postId, score);
339+
}
340+
}
341+
342+
async function saveScore(
343+
input: { postId: string; score: number },
344+
context: { userId: string }
345+
) {
346+
const { userId } = context;
347+
if (!userId ) throw new Error("userId required");
348+
if (!postId) throw new Error("postId required");
349+
350+
await redis.hSet(`scores:${input.postId}`, {
351+
[userId]: String(input.score),
352+
});
353+
354+
return { status: "ok" };
355+
}
356+
```
357+
358+
### Migrate logged-out user scores to their userId when they log in
359+
360+
- Next time the app runs and userId is present, save the score from localStorage to the user’s id in redis so that they get the credit for completing the game
361+
362+
```javascript
363+
async function migrateLoggedOutScoreOnAppInit(params: {
364+
userId?: string;
365+
postId?: string;
366+
}): Promise<void> {
367+
const { userId, postId } = params;
368+
if (!userId || !postId) return;
369+
370+
const loggedOutScore = readLoggedOutScore(postId);
371+
if (!loggedOutScore) return;
372+
373+
await saveScore({
374+
postId: loggedOutScore.postId,
375+
score: loggedOutScore.score,
376+
});
377+
378+
clearLoggedOutScore(postId);
379+
}
380+
```

0 commit comments

Comments
 (0)