> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nudj.cx/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Game with Nudj

> A worked integration using Planet Nudj: authenticated players, signed gameplay events, native achievements and an inline widget.

You can connect your own game to Nudj while keeping points, achievements and
rewards in Nudj. Planet Nudj uses this pattern for a browser game in which you
walk around a small planet, visit feature stations, race, cross stepping stones
and find treasure. The live loyalty widget appears in the character's phone.

This guide records the implementation and tests from 9 September 2026. It is a
worked example, including its deployment limits, rather than a game SDK.

## How the game is built

The website uses Next.js and React for the page and phone overlay, with Three.js
for the world, spherical movement, collision geometry and animation. The scene
reports actions to a same-origin Next.js route. That route validates progression,
saves it and delivers signed events to Nudj.

```mermaid theme={null}
sequenceDiagram
    participant P as Player and game
    participant S as Game server
    participant N as Nudj
    participant W as Widget in phone
    P->>S: Establish game session
    S->>N: API Link identity, then native session
    N-->>S: Native member credentials
    S-->>P: HttpOnly cookie and short-lived widget JWT
    P->>W: Official inline embed with userJwt callback
    W->>N: Authenticate the same external player ID
    P->>S: Ordered gameplay actions
    S->>S: Validate and commit state plus event outbox
    S->>N: POST events/claim with signed event proof
    N->>N: Evaluate achievement criteria
    W->>N: Read achievement and claim available reward
    N-->>W: Persisted points, XP and collectibles
```

Keep responsibilities explicit:

| Layer           | Owns                                                                                  |
| --------------- | ------------------------------------------------------------------------------------- |
| Three.js scene  | Movement, interactions, animation and input                                           |
| Game server     | Player identity, sequence, geometry/timing validation and delivery retries            |
| Nudj            | Native challenge progress, achievement qualification, points, XP and reward ownership |
| Official widget | The player's native loyalty interface                                                 |

Do not award points locally and again through Nudj for the same activity. Native
quiz completion and check-ins remain native actions. Custom game events cover
activities such as walking, racing and finding treasure.

## Keep the three tokens separate

| Token                      | Purpose                                                          | Where it belongs                                                |
| -------------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------- |
| Widget identity JWT        | Asserts your authenticated external player ID to Nudj            | Signed on your server; supplied to the widget through `userJwt` |
| Native member access token | Authenticates Integration API calls for the internal Nudj member | Kept in the game's protected server session                     |
| Signed event proof         | Proves a specific validated gameplay milestone                   | Created and submitted by the game server                        |

The game needs no Manager or MCP bearer token at runtime. Use administrative
credentials separately when configuring your community, achievements and rewards.

### Establish one player identity

For an existing product, use your authenticated account ID. Planet Nudj instead
creates an opaque `planet-<UUID>` on the server and keeps it in an encrypted,
HttpOnly, Secure, SameSite=Lax cookie. It never accepts a browser-supplied member
ID, email or points balance. Cookie deletion starts a new demo player; this is
not cross-device account recovery.

Create a dedicated signing key in your organisation's API configuration and
store it in your server environment. This Node.js example signs the widget
identity with the same standard-library mechanism used by the game:

```ts theme={null}
import { createHmac } from "node:crypto";

const keyId = process.env.NUDJ_SIGNING_KEY_ID;
const key = process.env.NUDJ_SIGNING_KEY;
if (!keyId || !key) throw new Error("Configure Nudj signing credentials");

function sign(payload: Record<string, unknown>) {
  const encode = (value: unknown) =>
    Buffer.from(JSON.stringify(value)).toString("base64url");
  const input = encode({ alg: "HS256", typ: "JWT", kid: keyId }) +
    "." + encode(payload);
  return input + "." + createHmac("sha256", key)
    .update(input).digest("base64url");
}

// externalPlayerId comes from your verified server session.
const now = Math.floor(Date.now() / 1000);
const userJwt = sign({
  sub: externalPlayerId,
  username: "Planet explorer",
  locale: "en",
  iat: now,
  exp: now + 300,
});
```

Use `kid` in the header and `sub` for the external ID. The current embed identity
flow accepts a maximum lifetime of 600 seconds; this game uses 300. A one-hour
legacy API Link example is not suitable for the widget identity flow.

### Obtain the native Integration API session

The widget identity JWT is not an Integration API member access token. The
game's server adapter establishes the native session through API Link:

1. Request `/api/link` on the configured Nudj **user-app origin**, with
   `userToken`, `clientId` and `callbackPath` query parameters.
2. Preserve Set-Cookie values in an isolated jar for this player. Follow a bounded
   number of redirects, checking every destination against the configured origin.
3. Request `/api/auth/session` on that origin using the same jar. Read
   `user.id` and `user.accessToken` without logging the response.
4. Call Integration `/me?communityId=...` with the native member token. Verify
   `id`, `externalUserId` and `organisationId` against your expected player and
   organisation before accepting the session.

Build the link with URLSearchParams so the nested callback is encoded correctly:

```ts theme={null}
const link = new URL("/api/link", userOrigin);
link.search = new URLSearchParams({
  userToken: userJwt,
  clientId: keyId,
  callbackPath: "/widget/planet-nudj?widget=1",
}).toString();
// Fetch server-side with the isolated cookie jar. Never log this URL.
```

`/api/link` and `/api/auth/session` are user-app routes, not Integration API
routes. This is the server bootstrap used by this example, not a dedicated OAuth
token-exchange endpoint. See [API Link authentication](/developer/api-link-user-authentication)
for the wider sign-in flow.

The demo encrypts native credentials with AES-256-GCM inside its HttpOnly cookie.
The session endpoint returns only public member fields and a fresh widget JWT.
When the native token expires, bootstrap again using the same external ID.
Coalesce the game and phone's first session request; otherwise simultaneous
first loads can create different players. The demo also serialises bootstrap
across tabs with the browser's Web Locks API where available.

## Put the official widget inside the game

Use the official loader's inline mode. Give its container a real width and
height, and create the container before loading the script:

```html theme={null}
<div id="planet-nudj-widget" style="width:100%;height:100%"></div>
```

```js theme={null}
window.nudjSettings = {
  community: "planet-nudj",
  trigger: "inline",
  container: "#planet-nudj-widget",
  autoOpen: false,
  userJwt: async () => {
    const response = await fetch("/api/planet-nudj/session", {
      credentials: "same-origin",
      cache: "no-store",
    });
    if (!response.ok) throw new Error("Unable to connect rewards");
    return (await response.json()).userJwt;
  },
};
// Append the configured user origin's /embed.js script after these settings.
```

Planet Nudj uses `https://derek-demos.nudj.cx/embed.js`. Use the user origin
configured for your organisation. The parent game's origin must be allowlisted
in the community embed settings.

Inline mode creates one iframe in the container, with no floating action button
or overlay panel. Do not create a floating widget and reparent its injected
`#nudj-embed-fab` or `#nudj-embed-panel` elements into your game.

In React, initialise after the container ref is mounted, retain the instance
while the phone is closed, and call `window.nudj("destroy")` on disposal. The
game obtains a valid session before appending the loader, because a failed
`userJwt` callback can otherwise lead to guest recovery. It displays a retry
state when session bootstrap fails.

The supported public views are:

```js theme={null}
window.nudj("navigate", "home");
window.nudj("navigate", "earn");
window.nudj("navigate", "spend");
window.nudj("navigate", "achievements");
window.nudj("navigate", "leaderboard");
```

There is no public challenge-ID or action-ID navigation command in the version
tested. Admin preview panel commands are not a merchant API. Keep task hints
outside the iframe rather than trying to change its cross-origin DOM.

If you listen for `nudj-widget-ready` or `nudj-widget-close`, validate both
`event.origin` and `event.source === iframe.contentWindow`. Do not treat a message
from another frame as authentication or achievement proof.

### Match the widget's gift count

The header's gift badge counts claimable, unclaimed achievements. It is not an
inbox unread count or a count of owned rewards. The widget posts the same value
to its parent as `{ type: "nudj-unread-count", count }` when it changes.

Reuse your existing message listener and current iframe reference:

```ts theme={null}
if (event.origin !== widgetOrigin || event.source !== iframe?.contentWindow) return;
const data = event.data;
if (
  data?.type === "nudj-unread-count" &&
  Number.isSafeInteger(data.count) &&
  data.count >= 0
) {
  setClaimableCount(data.count);
}
```

This is display data, never authority to grant a reward. Register the listener
before loading the iframe and remove it on disposal. Accept updates down to zero
after claims. Before the initial authenticated count read completes, its value
is unknown; a read failure must not manufacture a zero.

The world launcher loads its initial count through the existing authenticated
`GET /api/planet-nudj/game?notifications=1`. This keeps the widget iframe lazy:
an eager iframe can stop at cookie choice before it emits a count. The equivalent
native read used by the server is
`GET /api/v2/integration/achievements?communityId=<id>&status=live&limit=50&skip=0`
with the existing member headers. Count edges where
`activeTier.userState.isClaimable` is true and `isClaimed` is false. That is the
widget header's current query and predicate. Its 50-item page covers Planet
Nudj's 14 regular achievements; larger catalogues need pagination and a matching
widget fix before promising an accurate total.

Refresh on initial load, phone close and confirmed native progress, with no
interval polling. The native count lookup is read-only; the reused game GET
retains its existing deferred outbox delivery. Abort obsolete requests and use
a version counter so an older read cannot overwrite a newer iframe message.

The live test at website commit `8807a9d9` reused an isolated player's existing
First wander qualification. The closed launcher showed one before the iframe
opened. After Required-only consent, the native gift badge also showed one.
An actual pointer claim succeeded; closing the phone refreshed the count to
zero and hid the launcher badge. No member or game-store reset was used.

| Before the claim                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | After the claim                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <img src="https://mintcdn.com/nudj/BP4S3piUVZIZTtTH/images/enterprise/integrations/game-launcher-count-before.png?fit=max&auto=format&n=BP4S3piUVZIZTtTH&q=85&s=43e9faf2fc3700291612b0eef4663f73" alt="World launcher with one claimable achievement" width="173" height="72" data-path="images/enterprise/integrations/game-launcher-count-before.png" /> | <img src="https://mintcdn.com/nudj/BP4S3piUVZIZTtTH/images/enterprise/integrations/game-launcher-count-after.png?fit=max&auto=format&n=BP4S3piUVZIZTtTH&q=85&s=7e7b5c0fe13111a9b8e9fba0d99c2af3" alt="World launcher after the claim with the count badge hidden" width="152" height="48" data-path="images/enterprise/integrations/game-launcher-count-after.png" /> |

The exact result, live log and screenshots are in
`output/planet-audit/launcher-notifications`. Fixture checks at 1440 and 390
pixels cover invalid counts, wrong origins/frames, 99+ display, zero/unknown
states, accessibility and the single lazy iframe. These are live-preview results,
not verification of a production website deployment.

The loader documents `nudj:unread_changed` on `document` and
`nudj("on", "unread_changed", callback)`, but the deployed loader suppresses
these in inline mode. Emission currently depends on a closed panel and an
existing floating badge, tracked as NJ-2574. The validated iframe message above
is the supported protocol path available to an inline host today.

## Open Lite views as the same player

The passport annex opens four native Lite views from the character's phone.
Mono shows the existing welcome challenge on its own. Journey, Stamp card and
Calendar show the community's challenges. These views reuse native progress;
changing the layout must not create another completion or reward.

<Frame caption="The passport annex links to four native Lite views. Each opens in a new tab as the game's current player; looking at another format awards nothing.">
  <img src="https://mintcdn.com/nudj/BP4S3piUVZIZTtTH/images/enterprise/integrations/user-planet-lite-phone.png?fit=max&auto=format&n=BP4S3piUVZIZTtTH&q=85&s=891819bfdeda8043a2689806c665a7b5" alt="Planet Nudj phone showing the passport annex, its Mono format card and a button to open the native view" width="291" height="585" data-path="images/enterprise/integrations/user-planet-lite-phone.png" />
</Frame>

The game uses a same-origin handoff instead of relying on whichever Nudj account
the browser last used:

```html theme={null}
<a href="/api/planet-nudj/lite?format=mono"
   target="_blank" rel="noopener noreferrer">Open the Mono view</a>
```

The route accepts only `mono`, `journey`, `stamp` or `calendar`. It reuses the
existing game-session handler to verify or renew the native member, obtains a
fresh five-minute identity JWT, and returns a 303 through API Link. Its fixed
callbacks are `/lite/mono/{welcomeChallengeId}` and
`/lite/{journey|stamp|calendar}/{communitySlug}` on the configured Nudj user host.
Copy the renewed game cookie onto that response and set
`Cache-Control: private, no-store` and `Referrer-Policy: no-referrer`. Never log
the redirect's `Location` header: it contains the short-lived identity proof.

The implementation added 36 route lines and a 41-line focused check on 10
September 2026. Run `node scripts/check-planet-lite.cjs` in the website checkout.
Live tests used four separate browser contexts with only the game's cookie and
no Nudj cookies. Every format opened as the same member, retaining ten points,
thirty XP and one completed welcome challenge. A separate test clicked all four
links from the phone and confirmed the same identity and an empty referrer.
Invalid formats returned 400; cross-site requests returned 403.

The four Lite variants were already enabled for this community. No challenge
duplication or `enabledForSingleView` update was needed. Native presentation
defects remain tracked: Stamp's hard-coded Pampers cash graphic, Mono's completed
attempt and badge counters, and Journey's mobile heading overlap. Their presence
does not mean the underlying member progress was lost or awarded twice.

## Validate gameplay before sending an event

The game posts a small envelope to its own server:

```ts theme={null}
await fetch("/api/planet-nudj/game", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ sequence: 12, action: { type: "lake-jump" } }),
});
```

The server rejects cross-site requests, bodies over 2 KB, unknown action fields,
out-of-order sequences, unsupported IDs and caller-supplied award amounts.
It checks movement against server time and shared world geometry. Six ordered
landings qualify island arrival; digging at the island qualifies a separate
treasure milestone. Guided accessibility input uses the same native rewards.

State and its event outbox commit in one SQLite transaction. Send native HTTP
requests after committing, then mark delivered events. The browser serialises
actions, coalesces unsent movement samples and retries the same sequence and
payload if a response is lost.

Network delays can compress the interval between two valid actions arriving at
the server. For a transition that arrives before its minimum duration, the game
returns `425` and `retryAfterMs` without consuming its sequence. Retry the same
envelope after that delay. Invalid order or an expired run still returns `409`.
These are the example game's response conventions, not Nudj API status rules.

### Validate each puzzle input

The six camp games became interactive puzzles in website commit `1bf3b9c4`.
Each normal game uses the objects in the 3D world. Guided mode renders a
playable graphical version of the same mechanism and uses the same server rules.

| Camp             | Player interaction                                              | Native event             |
| ---------------- | --------------------------------------------------------------- | ------------------------ |
| Nib's clouds     | Rotate and fit a rung, then hold upward input to climb          | `planet.cloud.climbed`   |
| Sphinx           | Align the carved rings with the displayed symbols               | `planet.pyramid.opened`  |
| Wisp's cove      | Collect two pieces of moss and play a three-note instrument     | `planet.trail.completed` |
| Ideas lab        | Connect six tubes, launch the draft and earn the approval stamp | `planet.draft.approved`  |
| Storefront relay | Rotate six cable tiles to restore the route                     | `planet.relay.restored`  |
| Helpful tower    | Place four weights on hooks to balance the mechanism            | `planet.tower.opened`    |

For example, the game sends a cable rotation through its existing endpoint:

```ts theme={null}
await fetch("/api/planet-nudj/game", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    sequence: 12, // The next sequence for this game session.
    action: { type: "quest-play", control: "rotate-0" },
  }),
});
```

In `lib/planet-nudj/game.ts`, lines 144–168 at that commit, the server checks the
active mechanism, its allowed controls, normal-mode proximity and minimum input
interval before applying the move. `quest-puzzles.ts` evaluates pipe connections,
the note sequence, weight torque or symbol alignment. Only a solved mechanism
advances progress. The old `quest-step` action is rejected at a puzzle stage,
so it cannot bypass the controls. The existing outbox, stable nonce and native
achievement claim still own the once-only reward.

<Tabs>
  <Tab title="3D relay on mobile">
    <Frame caption="The 390-pixel 3D relay test tapped the cable objects. Physical targets were unobstructed, and Back to world preserved the active puzzle.">
      <img src="https://mintcdn.com/nudj/BP4S3piUVZIZTtTH/images/enterprise/integrations/game-relay-physical-mobile.png?fit=max&auto=format&n=BP4S3piUVZIZTtTH&q=85&s=ecf501ca938b81d8c03c425854b5ab62" alt="Actual mobile 3D relay with six rotatable cable tiles and Back to world control" width="390" height="844" data-path="images/enterprise/integrations/game-relay-physical-mobile.png" />
    </Frame>
  </Tab>

  <Tab title="Graphical guided relay">
    <Frame caption="The guided relay uses the same six-tile connection rules through graphical controls. It does not mark the puzzle solved automatically.">
      <img src="https://mintcdn.com/nudj/BP4S3piUVZIZTtTH/images/enterprise/integrations/game-relay-guided-mobile.png?fit=max&auto=format&n=BP4S3piUVZIZTtTH&q=85&s=89bddfda9e9f1b2d8ebbb04ebc7477eb" alt="Actual guided mobile relay showing six cable tiles, inlet and outlet labels" width="390" height="844" data-path="images/enterprise/integrations/game-relay-guided-mobile.png" />
    </Frame>
  </Tab>
</Tabs>

Version migration restarts incomplete older runs at the new definition. Completed
native rewards and their event records remain intact. Definition versions are
two for cloud, pyramid, lab, relay and tower, and three for Wisp. A completed
cosmetic preview still needs an explicit new playthrough before its first native
award; pending or delivered reward events prevent another rewarded replay.

The saved results contain six normal 3D runs, six graphical guided runs and one
additional mobile 3D relay run. Each earned ten points and ten XP through a native
claim, survived reload and rejected a duplicate claim with `400`. The normal
tests clicked the objects; Nib also used held climb input. Mobile checks used
browser-emulated touch, not a physical handset. Raw logs, emitted result JSON
and screenshots are in `output/planet-audit/puzzle-games`. These results concern
the preview connected to Nudj, not a production website deployment.

### Sign and submit the native event proof

After validation, reuse the server-side `sign` function from the identity example:

```ts theme={null}
const now = Math.floor(Date.now() / 1000);
const eventName = "planet.island.arrived";
const milestone = "first-island";
const eventToken = sign({
  organisationId,
  communityId,
  payload: {
    name: eventName,
    nonce: `planet:v1:${communityId}:${member.id}:${eventName}:${milestone}`,
    schemaVersion: 1,
    landings: 6,
    assisted: false,
  },
  expiresAt: (now + 300) * 1000,
  isSingleUse: true,
  clientSecretId: keyId,
  iat: now,
  exp: now + 300,
});

const response = await fetch(integrationBase + "/events/claim", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-user-access-token": "Bearer " + member.accessToken,
    "x-api-domain": integrationBase,
  },
  body: JSON.stringify({ eventToken }),
  redirect: "error",
  signal: AbortSignal.timeout(25_000),
});
if (!response.ok) throw new Error(`Nudj event claim failed: ${response.status}`);
```

`integrationBase` ends in `/api/v2/integration`. Keep it in trusted server
configuration. The token's `expiresAt` is milliseconds; JWT `iat` and `exp` are
seconds. The nonce stays stable for this member, event and milestone across
retries and reloads. Generating a new nonce for each request defeats duplicate
protection. Never return signed event proofs to the game client.

Configure a native achievement to listen to the exact custom event name. For
the island example, use `planet.island.arrived` with custom category and
subcategory. For native quiz completion, use `challenge_challenge-completion`
and filter `eventSourceId` to the actual quiz challenge ID.

## Register and test custom-event schemas

### Configure the achievement separately

A schema validates an event's payload. An achievement defines the qualification
and reward. Create the achievement through `POST /achievements` on the Admin
base with explicit queries and a matching tier criterion:

```json theme={null}
{
  "communityId": "<community-id>",
  "status": "draft",
  "progressPeriod": "open",
  "details": { "title": "Reviewed and approved" },
  "queries": [{
    "type": "event",
    "alias": "reviewed_and_approved",
    "isCommunityScoped": true,
    "parameters": {
      "eventName": "planet.draft.approved",
      "eventCategory": "custom",
      "eventSubCategory": "custom"
    }
  }],
  "tiers": [{
    "tierNumber": 1,
    "details": { "name": "Reviewed and approved" },
    "criteria": {
      "type": "count",
      "parameters": { "query": "reviewed_and_approved", "operator": ">=", "value": 1 }
    },
    "rewardDistribution": null
  }]
}
```

Then call `POST /achievements/{id}/distribution`:

```json theme={null}
{
  "tierNumber": 1,
  "pointsToDistribute": 10,
  "bonusXpToDistribute": 10,
  "rewardsConfig": { "mechanism": "all", "amountToDistribute": 0, "allocations": [] }
}
```

The distribution endpoint creates and links its event ID. Read the achievement
back and verify the exact event name, matching alias, open period, draft state
and persisted ten-point/ten-XP distribution. For an existing achievement, updates
use `POST /achievements/{id}`, unlike event-schema updates, which use PATCH.

The MCP convenience mapper still rewrote `planet.draft.approved` to
`custom_custom` in this test, tracked as NJ-2534. Explicit Admin queries preserved
the correct name. Do not infer success from a create response without read-back.

On 10 September, the owner approved publishing ready content. The lab, relay
and tower achievements were then changed to live with status-only Admin
updates. Read-back confirmed their exact queries, artwork and ten-point/ten-XP
distributions were preserved. The API added null defaults to optional tier
detail fields; this did not change the rules or rewards.

Publish the native achievement before enabling its game mapping. Both the game
queue and signer must reject disabled rewards. An ID alone does not activate a
draft. Earlier preview completions must not silently become reward events:
offer an explicit new playthrough for the live reward, and continue rejecting
replays once its native event has been recorded.

The publication checks, before the physical-puzzle redesign, passed normal and
guided runs for all three activated camps with their real
ordered steps, native ten-point/ten-XP claims, reload and duplicate-claim
rejection. An existing player who had finished the lab preview received nothing
on load or reload after activation. Choosing "Play again to earn the reward"
started at step zero; completing all six steps then qualified and paid once.
No member or game-store reset was used. The raw test records and screenshots
are saved under `output/planet-audit/activated-camps` in the website checkout.

### Register the payload schema

The Admin API can create and publish the same event schemas shown under
**Organisation settings → Event schemas**. Schema setup is organisation-scoped;
keep the administrative credential on your server or in protected tooling.
The game continues to use its member token and signed event proof.

### Find your schemas in the admin

Select your organisation, then open **Organisation settings → Developer → Event
Schemas**. For this demo, select **Derek Demos** and open the
[schema list](https://admin.nudj.cx/admin/settings/organisation/event-schemas).
The URL ending in `/event-schemas/new` opens an empty creation form. It does not
list definitions you have already created.

<Frame caption="Derek Demos has three published game-event schemas. The activity column counts valid and invalid incoming events over the last 24 hours.">
  <img src="https://mintcdn.com/nudj/BP4S3piUVZIZTtTH/images/enterprise/integrations/admin-event-schemas-list-view.png?fit=max&auto=format&n=BP4S3piUVZIZTtTH&q=85&s=e89fa75e144dde61233a3d722ac286b0" alt="Live Nudj admin list showing published walking, island-arrival and treasure schemas with valid and invalid event counts" width="1920" height="1080" data-path="images/enterprise/integrations/admin-event-schemas-list-view.png" />
</Frame>

These screenshots show the live demo in dark mode on 10 September 2026.
Activity counts change as players explore the game.

### Create and publish through the API

| Method and path, relative to the Admin base        | Purpose                                         |
| -------------------------------------------------- | ----------------------------------------------- |
| `GET /event-schemas`                               | List definitions before creating duplicates     |
| `POST /event-schemas`                              | Create a draft definition                       |
| `PATCH /event-schemas/{id}`                        | Update properties or publish it                 |
| `POST /event-schemas/validate`                     | Test a payload without storing an event         |
| `GET /event-schemas/{id}/events`                   | Inspect real events and their validation issues |
| `GET /event-schemas/activity-summary?sinceHours=1` | Compare recent valid and invalid event counts   |

Create a draft for the exact event name your game emits:

```json theme={null}
{
  "eventName": "planet.island.arrived",
  "description": "The player completed the six ordered island landings.",
  "properties": [
    { "name": "landings", "type": "number", "required": true, "integer": true, "min": 6, "max": 6 },
    { "name": "assisted", "type": "boolean", "required": true },
    { "name": "nonce", "type": "string", "required": true, "minLength": 1, "maxLength": 240 },
    { "name": "schemaVersion", "type": "number", "required": true, "integer": true, "min": 1, "max": 1 }
  ]
}
```

The create response returns its ID and `state: "draft"`. Use that ID to publish
with `PATCH /event-schemas/{id}` and body `{ "state": "published" }` after testing.
The event name cannot be changed through that partial update. Do not define a
property called `name`: it is reserved for the event name and is removed before
property validation. Unknown additional payload properties are currently allowed.

Open a schema and expand a property to inspect its type and constraints. The
island event requires an integer `landings` value of exactly six, so both its
minimum and maximum are six. `assisted` is a required boolean: both `true` and
`false` qualify, covering guided and manual play.

<Frame caption="The island schema's landings property is required, integer-only and limited to exactly six. The remaining required properties are nonce, schemaVersion and assisted.">
  <img src="https://mintcdn.com/nudj/BP4S3piUVZIZTtTH/images/enterprise/integrations/admin-event-schemas-island-properties.png?fit=max&auto=format&n=BP4S3piUVZIZTtTH&q=85&s=29f264ff0ec487f8bd5c9ade065e6bb3" alt="Published planet.island.arrived schema with landings expanded, showing minimum six, maximum six, Integer only and Required enabled" width="1920" height="1080" data-path="images/enterprise/integrations/admin-event-schemas-island-properties.png" />
</Frame>

### Test a payload and inspect real events

```json theme={null}
{
  "eventName": "planet.island.arrived",
  "payload": {
    "name": "planet.island.arrived",
    "landings": 6,
    "assisted": true,
    "nonce": "dry-run-example",
    "schemaVersion": 1
  }
}
```

Send that body to `/event-schemas/validate`. Valid results have `valid: true`,
`schemaExists: true` and no issues. A string such as `"yes"` for `assisted` or
five landings returns field issues. Dry-run validation writes no event.

Planet Nudj now has published definitions for `planet.walk.milestone`,
`planet.island.arrived` and `planet.treasure.found`. They drive First wander,
Against the current and X marks your spot through the existing exact-name
achievement queries. No second set of achievements or duplicate rewards is
needed. The game payload needs no new schema ID; lookup uses its event name
within the organisation.

| Event name              | Required gameplay properties                        | Native achievement  |
| ----------------------- | --------------------------------------------------- | ------------------- |
| `planet.walk.milestone` | `distance`: number, minimum 18                      | First wander        |
| `planet.island.arrived` | `landings`: integer, exactly 6; `assisted`: boolean | Against the current |
| `planet.treasure.found` | `assisted`: boolean                                 | X marks your spot   |

All three also require `nonce`, a string of 1–240 characters, and `schemaVersion`,
an integer equal to one. These checks validate the payload shape. Your game
server must still verify that the player actually performed the activity.

In the admin, **Test a payload → Validate** performs the same dry run. The
**Live events** panel shows stored ingress. Expand a row to compare its received
payload with its validation result. Use **Valid** or **Invalid** to filter it,
or pass `isValid=true` or `isValid=false` to `GET /event-schemas/{id}/events`.

<Frame caption="A real guided game run produced a valid island-arrival event. The separate dry-run panel confirms that six landings and a boolean assisted value match the schema; that dry run stores nothing.">
  <img src="https://mintcdn.com/nudj/BP4S3piUVZIZTtTH/images/enterprise/integrations/admin-event-schemas-valid-game-event.png?fit=max&auto=format&n=BP4S3piUVZIZTtTH&q=85&s=fab0f38ba5080b4651e76489967dd031" alt="Expanded valid island-arrival game event alongside a successful dry-run validation with landings six and assisted true" width="1920" height="1080" data-path="images/enterprise/integrations/admin-event-schemas-valid-game-event.png" />
</Frame>

<Frame caption="The deliberately invalid test event has five landings and assisted set to the string yes. Both the stored event and the dry run identify the two offending fields.">
  <img src="https://mintcdn.com/nudj/BP4S3piUVZIZTtTH/images/enterprise/integrations/admin-event-schemas-invalid-game-event.png?fit=max&auto=format&n=BP4S3piUVZIZTtTH&q=85&s=b4caf375e571e1d415d5c64ce10dd0ca" alt="Invalid island event showing landings must be at least six and assisted must be a boolean, with matching dry-run field errors" width="1920" height="1080" data-path="images/enterprise/integrations/admin-event-schemas-invalid-game-event.png" />
</Frame>

Actual keyboard movement and a fresh guided island run produced events with the
correct `validation.schemaId` and `validation.isValid: true` in the live schema
streams. Their native achievement claims paid 20/20 for walking and a combined
30/30 plus the key for island arrival and treasure. Missing, out-of-range and
wrong-type payloads were rejected in the test endpoint; actual signed invalid
submissions were retained as invalid diagnostic rows and awarded nothing.

There is an open ingress error-contract defect, tracked internally as NJ-2556:
a published-schema rejection returns 500 from `/integration/events/claim`, and
retrying its already rejected nonce returns 200 even though the stored event
remains invalid. The admin screenshot's HTTP 400 help text does not describe
this signed-claim failure correctly. Check native validation and achievement state when diagnosing
this case. Do not credit rewards or manufacture a new nonce to turn a rejected
payload into a successful milestone. This differs from replaying an originally
valid event, which is an intended successful no-op.

## Read progress and claim native rewards

All Integration calls below use the native member headers shown above:

| Method and path, relative to Integration base      | Purpose                                               |
| -------------------------------------------------- | ----------------------------------------------------- |
| `GET /me?communityId=...`                          | Verify identity and read balance/XP                   |
| `POST /events/claim`                               | Submit the signed event proof                         |
| `GET /achievements/{id}`                           | Read native qualification and claim state             |
| `POST /achievements/{id}/claim` with `{}`          | Claim an earned achievement                           |
| `GET /me/badges?communityId=...&limit=100&skip=0`  | Read collectible badge ownership                      |
| `GET /me/rewards?communityId=...&limit=100&skip=0` | Read ordinary reward assets, such as a purchased pass |
| `GET /challenges/{id}`                             | Read native challenge completion prerequisites        |

Event processing is asynchronous. A successful event POST does not mean the
achievement is already claimable. Poll with a bounded delay and read:

```ts theme={null}
achievement.userState.isComplete;
achievement.activeTier.userState.isClaimable;
achievement.activeTier.userState.isClaimed;
achievement.activeTier.userState.earnedPoints;
achievement.activeTier.userState.earnedXp;
```

After claiming, re-read the native member balance and ownership. Repeating an
already-completed event claim returned `200` without another payout in testing.
Repeating an achievement claim returned `400`; reconcile its native claimed state
before deciding whether a retry succeeded. Do not swallow every `400` as success.

Badges and ordinary rewards are separate collections. The Secret island key is a
badge and appears in `/me/badges`. `/me/rewards` intentionally excludes it. The
Planet explorer pass is an ordinary reward asset. Follow `totalCount` and `edges`
pagination, and check member, community and expiry when using ownership to unlock
gameplay. Creating a reward does not place it in the shop; it needs a shop allocation.

## Implementation size and checks

The website's `1bf3b9c4` commit contains 868 physical lines across the eleven
selected integration, puzzle-rule and puzzle-interface files below, measured
on 10 September 2026 by counting newlines in `git show` output. This includes
blank lines and comments. It excludes the 3D scene and mechanism meshes, other
game files, tests, artwork and community configuration; it is not the total game
size. Measuring the commit keeps concurrent uncommitted UI changes out of this
snapshot.

| Website path                                  | Lines |
| --------------------------------------------- | ----: |
| `lib/planet-nudj/nudj-api.ts`                 |   225 |
| `lib/planet-nudj/session.ts`                  |    45 |
| `lib/planet-nudj/client-session.ts`           |    26 |
| `app/api/planet-nudj/session/route.ts`        |    54 |
| `app/concepts/little-planet/real-widget.tsx`  |   122 |
| `lib/planet-nudj/game.ts`                     |   181 |
| `lib/planet-nudj/game-store.ts`               |    21 |
| `app/api/planet-nudj/game/route.ts`           |    43 |
| `app/concepts/little-planet/game-events.ts`   |    31 |
| `app/concepts/little-planet/quest-puzzles.ts` |    54 |
| `app/concepts/little-planet/quest-hud.tsx`    |    66 |

Run the focused checks in the website repository:

```bash theme={null}
node scripts/check-planet-nudj-api.cjs
node scripts/check-planet-nudj-session.cjs
node scripts/check-planet-game.cjs
node scripts/check-planet-game-client.cjs

# Protected credentials are read from a file, never passed as token arguments.
PLANET_NUDJ_ENV_FILE=/protected/path/server.env \
  node scripts/check-planet-game-live.cjs
PLANET_NUDJ_ENV_FILE=/protected/path/server.env \
  node scripts/check-planet-game-live.cjs --guided
```

Live standard and mobile guided island runs proved actual game input, all six
landings, digging, two native achievement completions, a combined claim delta of
30 points and 30 XP, and key ownership. Reload preserved member and balance.
Repeat claims, another member's unearned claims, forged input and cross-site
requests were rejected. Those runs claimed through the Integration API; they
do not by themselves prove the widget's claim buttons.

The original cloud, pyramid and six-step Wisp quests also have six recorded
normal/guided passes: each qualified, claimed 10 points and 10 XP, survived
reload and rejected a duplicate claim. A fresh production read confirms all
six exact achievement records remain complete and claimed for those amounts.
Their screenshot index is retrospective because the original runs did not save
standalone result JSON; the fresh database read is stored separately. The new
lab, relay and tower rewards were subsequently published after owner approval;
their activation checks are separate from these earlier original-quest runs.

An independent live phone check confirmed that the iframe and game session
resolved the same native member, with one inline iframe and no floating launcher.
Keep UI claims, keyboard/touch paths and other activities in the test record as
they are checked; a passing island run is not proof of every game path.

## Limits and integration pitfalls

| Observation                                                                       | What to do                                                                                                                                        |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Newly generated signing key returned `kid_unknown` while configuration was cached | Verify the key and environment first; the observed demo required a platform-configuration cache refresh. Never expose the secret while debugging. |
| Localhost was not allowlisted for the live community                              | Test on the configured allowed origin. Do not spoof Origin to work around embed validation.                                                       |
| Day-one streak payouts can recur after a broken streak is rebuilt                 | Use a separate once-ever achievement for a one-time onboarding bonus.                                                                             |
| A phone refresh can retain stale native progress                                  | Reconcile native state and refresh the official instance when external gameplay delivers an event.                                                |
| Node exists by absolute path but Turbopack workers cannot find it                 | Put the selected Node bin directory on PATH before starting Next.js.                                                                              |
| Passing Node `--env-file` through this Next dev launch failed                     | Source the protected environment in the launch shell; do not propagate `--env-file` into NODE\_OPTIONS.                                           |

The example uses Node 22's built-in SQLite in WAL mode on one persistent preview
host. Use shared transactional storage for multiple application instances, or
deliberately provision one persistent host. An ephemeral serverless filesystem
does not provide this persistence guarantee.

Movement validation bounds browser telemetry using geometry and server time. A
scripted client that follows those constraints can still play. This is not an
authoritative multiplayer physics server, and it must not prove real purchases
or other high-value commerce. Keep sample orders and receipt demonstrations out
of genuine purchase events.

## Open issues found by the main-journey test

These failures remain separate from the passing island and race tests. Do not
use the transient widget display as proof of persisted state.

**Inline check-in scope.** A real widget check-in wrote the correct community ID
in `payload.communityId` and `eventSourceId`, but omitted top-level `communityId`.
The widget briefly displayed one day; a native `/me/streaks` read still showed
zero, and the community-scoped achievement remained locked. The shared event
logger took community scope from a navigation cookie that was absent in this
inline session. A source fix is being prepared to pass the known target explicitly.

For the example's first-check-in achievement, a temporary query selects the exact
community as `eventSourceId`, keeps native category/subcategory/name and preserves
member and organisation scope, without requiring the missing top-level field.
Changing this query did not recalculate existing saved progress on read. It also
does not repair the durable streak or an event carrying a different, incorrect
top-level community ID. Do not generate a replacement nonce to force a payout.

**Challenge points and XP.** The native welcome challenge completed and awarded
20 XP plus its collectible, but zero points despite a configured 20-point reward.
With fixed points distribution disabled, challenge points are multiplied by
the player's action-XP ratio. In this example the action explicitly awards zero
XP, while the ratio denominator uses the organisation's 50-XP action default.
Completion bonus XP does not correct that ratio. Validate actual awarded points
for your configured XP policy; do not change organisation-wide defaults to repair
a single demo.

The welcome, quiz, check-in, pass and factory journey is not yet verified end to
end. These native issues need resolution and another live run before release.

## Record a game check-in through a custom event

The older integration used a custom event named `check-in`. A historical
migration converted it to native `community / check-in`. Current
`POST /integration/events/claim` emits `custom / custom` and preserves the exact
`payload.name`; adding a category parameter cannot turn it into a native event.

You can still configure a streak to accept your game's own check-in event:

```json theme={null}
{
  "triggerEvents": {
    "platform": ["check-in"],
    "custom": ["planet.check-in"]
  }
}
```

Preserve the full existing trigger set when updating it. The server sends the
signed custom event only after the player's explicit check-in action. This
example uses a `check-in-YYYY-MM-DD` milestone in the verified organisation's
Europe/Berlin timezone, not the player's browser timezone. The signed payload
contains the same day and timezone, and the signer rejects mismatches.

Cap proof expiry at organisation midnight. Native event time is its ingestion
time, so an old check-in must not be replayed tomorrow as if it happened today.
The example retains expired outbox entries with a reason and asks the player to
check in again. Verify native `/me/streaks` after delivery, including repeated
clicks, native and custom events on one day, and date boundaries. The live game API path produced a properly scoped event, a durable one-day
streak and a claimable first-check-in achievement. Same-day repetition left one
delivered daily event. Claiming through the widget handler added 30 points and
30 XP, and reload preserved them. The repeatable browser check now covers the actual game button, cookie consent,
scaled pointer claim, native read-back and reload for a fresh player.

The same test player's native quiz was completed through all three widget
questions. Invoking the real widget claim handler produced a200 Integration
achievement claim and increased points by20 and XP by20; native state showed
claimed. The later repeatable phone test also verified the actual pointer path. The
inline loader scales its iframe; map pointer coordinates through the frame
rectangle and its internal viewport after scroll and animation settle.

The API streak date read also exposed a timezone defect: a check-in at
22:21 UTC was already September 10 in the configured Europe/Berlin timezone,
but `recentCompletedDates` reported September 9. The single and batch helpers
use server-local day comparisons and UTC date slicing. This is tracked for
repair; keep the game calendar aligned to the organisation rather than copying
the incorrect API timeline date.

## Native game results and reward configuration

The native memory game exposed a zero-value default error. With three pairs
worth ten score each, `timeBonus: 0` and a 30–30 reward band, a completed round
scored 73 because the game replaced zero with a multiplier of one. Native
read-back confirmed completion with zero points and XP, since 73 missed the
configured band. Treat a saved game configuration and its rendered result as
separate checks.

For the example, explicitly enabling a one-point-per-second bonus and assigning
a 30–120 completed-round band preserved the intended ten-point/ten-XP reward.
A fresh live test then completed the nested game, submitted score 117 and received
ten points and ten XP. The source correction for explicit zero remains pending
release; a configuration workaround does not prove that source bug is fixed.

When replacing game rewards through MCP, supply both the score conversion and
the distribution bands in the same assignment, then read them back:

```json theme={null}
{
  "operation": "assign_distribution",
  "gameConfigId": "<native-game-config-id>",
  "scoreConversion": { "pointsPerScore": 0, "xpPerScore": 0 },
  "distributions": [
    { "minScore": 30, "maxScore": 120, "pointsToDistribute": 10, "bonusXpToDistribute": 10 }
  ]
}
```

This operation replaces the reward configuration. Sending only bands cleared
the existing conversion; a conversion-only nested update cleared the bands.
A top-level `scoreConversion` on `games.update` was ignored by its mapper. Use
the supported full assignment shape while that contract is being clarified.

## Referrals, birthdays and boosts

### Referrals require an accepted code and qualification

Referral setup has separate rewards for the referrer and the invited player,
plus a backing achievement defining what the invited player must do. In Planet
Nudj, each side receives ten points and ten XP after one native challenge
completion. Merely opening a link or accepting its code does not earn that award.

Use the native member token for these Integration API calls:

| Method and path                                           | Input or purpose                                                           |
| --------------------------------------------------------- | -------------------------------------------------------------------------- |
| `POST /me/referral/codes`                                 | `{ "communityId": "<community-id>" }` creates or returns the player's code |
| `GET /me/referral/codes?communityId=...&limit=100&skip=0` | Read codes, `usedByCount` and each entry's `conditionsMet`                 |
| `POST /me/referral/accept`                                | `{ "code": "<referral-code>" }` accepts the code for the current player    |
| `GET /me/referral/status?communityId=...`                 | Read `referredBy` and confirm the accepted relationship                    |

Two isolated game players verified the native flow. After code acceptance, the
invited player completed the welcome challenge through the real phone. Each side
received ten referral points and ten XP. The code recorded one use with
`conditionsMet: true`; repeated acceptance returned 200 without another payout.
The welcome challenge's separate zero-point defect remained visible in this test.

Two integration gaps remain. New code creation succeeds, but the widget does not
refresh its code query until reload, tracked as NJ-2558. The game's incoming
`referralCode` URL parameter also needs a server handoff. The tested acceptance
API infers the community from the code and cannot enforce the caller's expected
community before writing. NJ-2559 adds that check. Do not put an Admin bearer in
the game or rely on a check after acceptance to repair a wrong-community write.

### Add giveaway entries to a referral reward

Giveaway prize supply and entry supply are separate. The Christmas AirPods 5
draft uses one prize and an unlimited entry pool. The relevant Admin reward
fields are:

```json theme={null}
{
  "status": "draft",
  "allocationsType": "entries-only",
  "assetsSupply": 1,
  "entriesSupply": "Infinity",
  "isSharedAssetsSupply": true,
  "isSharedEntriesSupply": true,
  "maxAssetsPerUser": 1,
  "entryPointsPrice": 0,
  "isManualGiveaway": false
}
```

Create the complete reward through `POST /api/v2/admin/rewards` and read it back before
attaching it. At launch, the proposed referral package adds an allocation with
`allocationType: "entries"`, the giveaway's `rewardId` and
`amountToDistribute: 1` to the **referrer** side. Preserve the existing points and
XP fields. One newly qualified friend should produce one entry; a share-button
click alone does not establish a referral.

This AirPods reward remains draft with no allocation attached. Its proposed
Christmas Day 2026 draw is not a public launch announcement. Participant
eligibility, real identity checks, isolated entry-award tests, the game arrival
handoff and physical prize fulfilment still need completing before launch.
Draft status does not schedule the automatic giveaway job.

The MCP `reward_giveaways` tool currently hard-codes unlimited prize supply and
omits draw-mode controls, tracked as NJ-2571. Use the supported Admin API fields
to configure one prize. Reward `tags` and `campaigns` accept existing entity IDs,
not new text labels; invalid labels currently cause an ObjectId HTTP500,
NJ-2570. Omitting optional tags allowed the draft to be created successfully.

### Birthdays follow the real calendar

The native `question-birthday` action writes `birthDay`, `birthMonth` and
`birthYear` to the member profile. An isolated player entered 31 December 2000
through the phone. `/me` and a reload returned the same date; the widget marked
the activity completed and disabled its button. Points and XP did not increase.

Planet Nudj's birthday reward is configured for twenty points and twenty XP on
the birthday, with zero advance days, a one-day membership minimum and the date
locked after entry. This proves date capture and no early award. It does not
prove a future scheduled payout; keep that outcome pending until its real date.

### Verify boost expiry with another earned reward

The live one-hour points and XP boosts doubled a ten-point/ten-XP treasure claim
to twenty of each. After both effects actually expired, the same player walked
19.7 world units and qualified for First wander. Its eventual claim paid the
base twenty points and twenty XP, and native `communityData[].effects` was empty.

The first post-expiry claim returned 500. Native read-back showed no award and
an unclaimed achievement, then an identical retry succeeded once. Better Stack
recorded a MongoDB write conflict during points distribution, tracked as NJ-2560.
The points and XP helpers launch expired-effect cleanup outside the reward
transaction, a suspected race that still needs repair. An expired entry remaining
in a read response is not itself proof of an active multiplier: cleanup is lazy.

## Distinguish visible tier progress from persisted promotion

The configured tiers use lifetime points earned: New neighbour at zero,
Explorer at 80 and Maker at 140. A real guided cloud quest and phone claim
increased the main player's lifetime points from 130 to 140. The widget showed
Maker with 80 spendable points. The player then bought another 60-point pass
through the phone's quantity and confirmation screens. Available points fell to
20, while lifetime points stayed at 140, XP stayed at 200 and Maker remained.
Reload preserved the result and both purchased pass assets. A duplicate quest
claim returned `400`.

<Frame caption="After an actual 60-point purchase, the live widget still shows Maker at 140 lifetime points, with 20 points left to spend.">
  <img src="https://mintcdn.com/nudj/BP4S3piUVZIZTtTH/images/enterprise/integrations/game-maker-tier-header.png?fit=max&auto=format&n=BP4S3piUVZIZTtTH&q=85&s=919f574bda349928b8f74e8af9e97c1e" alt="Actual Planet Nudj widget header showing Maker tier and 20 spendable points after a reward purchase" width="330" height="206" data-path="images/enterprise/integrations/game-maker-tier-header.png" />
</Frame>

The database read before that purchase confirmed 80 available points,
140 lifetime points and 200 XP, but no `loyaltyTierState`. The widget derives
the visible label from lifetime earnings. This is the stored-promotion defect
tracked as NJ-2554. These demo tiers have 1x multipliers and no entry reward
configured; this run does not prove promotion history or an entry payout.

## Purchase and factory proof

The same player completed the welcome and quiz, checked in, then earned an
optional island achievement. With 70 legitimately earned points, the player
bought the 60-point explorer pass through the native shop. Available points fell
to 10 while lifetime points stayed 70 and XP stayed 120. `/me/rewards` returned
the pass asset for that member and community.

The server then confirmed all factory prerequisites. The player entered an idea
and ran the reduced-motion factory through its real timed game endpoint. The
native Programme maker achievement completed, and its pointer claim added
20 points and 20 XP. Reload preserved the pass, factory milestone and balances.
This proves the downstream purchase and factory contract. The advertised
main-only welcome/quiz earnings still require the challenge-XP repair described
above; optional earnings do not remove that release hold.

The same normal-factory check has a strict `--main-only` mode. It skips optional
Curious mind and First wander claims, then checks every native balance:

| Stage                   | Available points | Lifetime points |  XP |
| ----------------------- | ---------------: | --------------: | --: |
| Welcome complete        |               20 |              20 |  20 |
| Quiz complete           |               50 |              50 |  50 |
| First check-in claimed  |               80 |              80 |  80 |
| Explorer pass purchased |               20 |              80 |  80 |
| Factory claimed         |               40 |             100 | 100 |

```bash theme={null}
PLANET_NUDJ_ENV_FILE=/protected/path/server.env \
PLAYWRIGHT_MODULE=/path/to/installed/playwright \
  node scripts/check-planet-normal-factory.cjs --main-only
```

The pre-release run stopped at welcome with zero available points, zero lifetime
points and 20 XP. It exited with failure before any optional claim. This is
current evidence for NJ-2532. It does not prove the later stages; rerun after
the repair is released. The script reports `mainOnlyFundingVerified: true`
only after the whole sequence, reload and duplicate protection pass. Default
mixed-funding mode always reports false for that field.

Run the repeatable phone and native-game flow in the website repository:

```bash theme={null}
PLAYWRIGHT_MODULE=/path/to/installed/playwright \
  node scripts/check-planet-phone-live.cjs --memory
```

It creates an isolated test member and checks actual pointer input, same-member
widget identity, check-in/claim persistence, duplicate rejection, no floating
launcher, no extra phone header, and the native memory reward. It does not
claim coverage of every station or future calendar outcome.

## Award discoveries after completing a lesson

Secret seeker needs three different discoveries. The team, analytics and
integration lessons each record `planet.discovery.completed` after the player
finishes an activity:

| Lesson                     | Completion                                                                | Event payload `discoveryId` |
| -------------------------- | ------------------------------------------------------------------------- | --------------------------- |
| The right-key cabinet      | Match three jobs to Viewer, Moderator and Manager, then finish the puzzle | `team`                      |
| The control room           | Identify points as the spendable balance                                  | `analytics`                 |
| The storefront loading bay | View both widget placements and confirm                                   | `integrations`              |

Await delivery to Nudj before showing "Discovery saved to Nudj." If delivery
fails, keep the activity's retry button available. Opening a lesson does not
qualify. Art, receipt examples, Lite formats and disabled draft quests do not
emit these discovery events.

<Frame caption="Completing the three-job role puzzle records one discovery. The saved message appears after Nudj acknowledges delivery; it does not change anyone's actual permissions.">
  <img src="https://mintcdn.com/nudj/BP4S3piUVZIZTtTH/images/enterprise/integrations/game-discovery-lesson-saved.png?fit=max&auto=format&n=BP4S3piUVZIZTtTH&q=85&s=c40df8817a63b573837e0e27323949be" alt="The right-key cabinet phone lesson after completing the role puzzle, showing Discovery saved to Nudj" width="291" height="585" data-path="images/enterprise/integrations/game-discovery-lesson-saved.png" />
</Frame>

For normal walking, retain the server's station-proximity check. The accessible
guided path records a server-side lesson start and requires the same lesson ID
and at least 900 ms before completion. It preserves the player's physical
position. Both paths use the same signed-event and nonce contract described
above, so repeating a completed lesson creates no additional native event.

The guided player's three completions produced exactly three scoped native
events. A real pointer claim in the phone paid 20 points and 20 XP. Reload
preserved the reward; repeating the integrations lesson kept the event count at
three, and a duplicate achievement claim returned `400`. The same run verified
fresh cookie consent, an unobstructed Back button and no extra phone header or
floating launcher.

<Frame caption="The actual Secret seeker claim in the embedded widget. Native member read-back confirmed both 20 points and 20 XP.">
  <img src="https://mintcdn.com/nudj/BP4S3piUVZIZTtTH/images/enterprise/integrations/game-secret-seeker-claimed.png?fit=max&auto=format&n=BP4S3piUVZIZTtTH&q=85&s=214143d532b650594c719419b343d390" alt="Native Secret seeker reward confirmation in the game phone showing Claimed and plus 20 points" width="291" height="585" data-path="images/enterprise/integrations/game-secret-seeker-claimed.png" />
</Frame>

Run the live guided check from the website repository:

```bash theme={null}
PLAYWRIGHT_MODULE=/path/to/installed/playwright \
PLANET_AUDIT_DIR=/path/to/verification-output \
  node scripts/check-planet-discoveries-live.cjs
```

Normal walking separately passed all three lesson completions, native event
delivery and a real phone claim for 20 points and 20 XP. Reload retained the
three discoveries and reward; a duplicate claim returned `400` with unchanged
balances. The normal race and factory checks also passed with native claims and
duplicate protection. The factory used optional walking earnings; the
advertised main-only funding still waits for the challenge-points repair.
