# Stetson Academy game builder guide

**Live copy (always current):** https://www.stetsonacademy.com/games/game-builder.md  
(also https://www.stetsonacademy.com/coding/game-builder.md)

Share this file with anyone building a game on grok.com / grok.me for Stetson Academy. Games can be **any subject** — coding, math, history, reading, science, not only programming.

The game is **hosted on grok.me**. Stetson Academy **starts the session, decides who may play, and stores the game’s own save data**. The game must not invent its own login. The game owns what “progress” means; the school only stores it, restores it on the next Play, and shows a short summary to staff and students.

**API host:** `https://www.stetsonacademy.com`

If a request fails CORS, the game origin is not registered on the school site. Send the staff the exact origin (see below) — do not try to work around CORS.

---

## 1. How a student gets into the game

1. The student signs in on stetsonacademy.com and clicks **Play**.
2. The school site checks that the game is published and that the student is allowed (class assignment and/or prior-game gates).
3. The school creates (or resumes) an attempt and redirects the browser to:

```
https://YOUR-GAME.grok.me/...?session=SIGNED_TOKEN
```

4. The game reads `session` from the query string and uses it as a Bearer token on every school API call.
5. The token is valid for **4 hours**. After that, ask the student to click Play again from the school site. Their save is still on the school site; a new token loads it.

Do **not** require the student to type a password inside the game. Do **not** send the token to any other host. Do **not** keep the real save only in `localStorage` — shared iPads will clobber it. School save is the source of truth.

### Read the session token

```js
const params = new URLSearchParams(window.location.search);
const session = params.get("session");
if (!session) {
  document.body.innerHTML =
    "<p>Open this game from your Stetson Academy portal. Direct links do not work.</p>";
  throw new Error("missing session");
}
```

If the game uses a hash router, still read `window.location.search` (the school appends `?session=`, not `#session=`).

---

## 2. CORS / origin (required)

School APIs only allow the **Origin** of a game registered on the school site (published or draft). Draft origins are allowed so staff can **Preview** before publishing.

When staff add the game they need two values from you:

| Field | What to send |
|---|---|
| **Play URL** | The full URL students should land on (path + any required query except `session`) |
| **CORS origin** | Scheme + host only, no path. Example: `https://stetson-helix.grok.me` |

Origin is `location.origin` in the running game. It must match exactly (https, host, no trailing slash). If grok.me gives the game a new host after a rebuild, send the new origin.

The school API allows headers `Authorization` and `Content-Type`, methods `GET, POST, OPTIONS`. Cookies are not used. Do not set `credentials: "include"`.

---

## 3. API overview

Base: `https://www.stetsonacademy.com/api/coding`

| Method | Path | When |
|---|---|---|
| `GET` | `/session` | On load, **before** gameplay. Returns last save. |
| `POST` | `/events` | Save snapshot, optional milestone, heartbeat |
| `POST` | `/complete` | When this run is finished (does **not** wipe the save) |
| `OPTIONS` | same paths | Browser preflight (handled for you) |

Every call:

```
Authorization: Bearer <session>
Content-Type: application/json    (POST only)
```

`GET /session` also accepts `?session=` if the header is omitted. **POST must use the Bearer header.**

Success responses include `"ok": true`. Errors look like:

```json
{ "ok": false, "error": "session expired" }
```

| HTTP status | Meaning |
|---|---|
| 400 | Missing `event_type`, or `save` / `summary` too large or not an object |
| 401 | Missing, invalid, or expired session |
| 403 | Game unpublished |

On 401, show “Go back to the school portal and click Play again.” Do not retry with a new token.

---

## 4. Progress model (read this)

Staff do **not** configure a fixed score scale for your game. You decide the shape of progress.

The school stores **one save slot per student per game**. Staff Preview uses a separate save slot (per teacher), so testing never overwrites a student.

| Field | Who owns it | Purpose |
|---|---|---|
| `save` | **The game** | Opaque JSON snapshot. Restored on the next session. School never interprets keys. |
| `summary` | **The game**, for humans | Short snapshot for the student catalog and staff “Student progress” table. |

**`save` is replaced, not merged.** Each POST should send the full snapshot the game needs to resume (level, inventory, code, flags, cursor, …). Keep it JSON-serializable. Max **64 KB**.

**`summary`** is optional but you should send it whenever you save, or staff and students see “Saved” with no detail. Max **8 KB**. Suggested keys:

| Key | Type | Shown as |
|---|---|---|
| `label` | string | One line, e.g. `"Hangar 2 · 3/8 circuits"` |
| `pct` | number 0–100 | Progress bar |
| `score` | integer | Numeric column / optional gates |
| `milestones` | string[] | Named flags; can unlock later games if staff set a gate on that key |
| any other scalar | string / number / bool | Stored; staff table ignores most of them |

Extra summary keys are stored. Arrays and nested objects besides `milestones` are dropped from summary (put those in `save`).

Completing a run does **not** clear the save. The next Play still returns it so the student continues. To start over, POST `event_type: "reset"`.

---

## 5. `GET /api/coding/session`

Call this first, then restore from `progress.save` if it is an object.

### Response (shape)

```json
{
  "ok": true,
  "api": {
    "session": "https://www.stetsonacademy.com/api/coding/session",
    "events": "https://www.stetsonacademy.com/api/coding/events",
    "complete": "https://www.stetsonacademy.com/api/coding/complete"
  },
  "student": {
    "id": "uuid",
    "first_name": "Billy",
    "display_name": "Billy"
  },
  "game": {
    "slug": "helix",
    "title": "Helix Command",
    "description": "",
    "max_score": 0,
    "objectives": []
  },
  "attempt": {
    "id": "uuid",
    "started_at": "2026-08-30T13:00:00+00:00",
    "ended_at": null,
    "score": 120,
    "status": "in_progress"
  },
  "preview": false,
  "assignment": null,
  "progress": {
    "save": {
      "scene": "hangar",
      "circuit": 3,
      "code": "for i in range(4): ..."
    },
    "summary": {
      "label": "Hangar 2 · 3/8 circuits",
      "pct": 37.5,
      "score": 120,
      "milestones": ["tutorial"]
    },
    "score": 120,
    "best_score": 120,
    "completed_objective_keys": ["tutorial"],
    "updated_at": "2026-08-30T13:18:00+00:00",
    "resumed": true
  }
}
```

- New player: `progress.save` is `null`, `progress.resumed` is `false`. Start at your intro.
- Returning player: apply `progress.save` **before** gameplay. `resumed` is `true` when a non-empty save exists.
- `game.objectives` is only for optional staff-defined milestone keys. Most games can ignore it and put everything in `save` + `summary.milestones`.
- `assignment` is `null` when the game is not tied to a class window.
- `preview` is `true` when a teacher launched **Preview** from Teacher → Games. Treat it like a real session (save/restore still run). Display name looks like `Maya (preview)`. Do not treat it as a student record. Preview saves are stored separately from students.

### Staff Preview

Teachers click **Preview** on a game (including drafts). That redirects to the same play URL with `?session=`. Gates and class assignments are skipped. Unpublished games work so staff can test before Publish.

Your game should:

1. Boot with `GET /session` as usual.
2. If `boot.preview === true`, you may show a small “Preview” badge. Keep saving.
3. Do not disable APIs in preview. Staff are testing the real save/restore path.

### Privacy

The school only sends **first name / nickname**, never last name or email. Do not display or store extra student identity. `student.id` is an opaque UUID; do not show it in the UI.

Prefer `body.api.events` / `body.api.complete` over hard-coding URLs when those fields are present.

---

## 6. `POST /api/coding/events`

Send JSON. `event_type` is required (`type` is accepted as an alias).

### Save (required for resume)

Do this on checkpoints, after meaningful progress, and on a debounce (about every 10–15 seconds of play is enough). Also save when the tab hides (`visibilitychange` / `pagehide`).

```json
{
  "event_type": "save",
  "save": {
    "scene": "hangar",
    "circuit": 3,
    "code": "for i in range(4): ..."
  },
  "summary": {
    "label": "Hangar 2 · 3/8 circuits",
    "pct": 37.5,
    "score": 120,
    "milestones": ["tutorial"]
  }
}
```

Aliases for `event_type`: `save`, `progress`, `checkpoint`, `state`.  
Alias for `save`: `state`.

The school **replaces** the stored save with this object. Send the full snapshot every time.

### Reset (new playthrough)

```json
{ "event_type": "reset" }
```

Alias: `new_game`. Clears `save` and `summary` for this student on this game. History of earlier attempts is kept.

### Optional milestone

Only needed if staff will gate another game on a named flag.

```json
{
  "event_type": "objective",
  "objective_key": "tutorial",
  "payload": { "scene": "hangar" }
}
```

You can skip this if the same key is in `summary.milestones`.

### Other `event_type` values

| `event_type` | Also accepted | Effect |
|---|---|---|
| `save` | `progress`, `checkpoint`, `state` | Stores `save` + `summary` |
| `reset` | `new_game` | Clears save |
| `objective` | `objective_complete`, `complete_objective`, `unlock` | Records `objective_key` |
| `score` | | If `payload.score` is an integer, score becomes `max(current, payload.score)` |
| `complete` | `finish`, `completed` | Marks the attempt complete (still call `/complete` at the end of a run). Save is kept. |
| `heartbeat` | anything else | Stored for teachers; no save change unless `save` is present |

`payload` should be a JSON object. Extra fields on the body are stored if `payload` is omitted.

### Response

```json
{
  "ok": true,
  "event": {
    "id": "uuid",
    "event_type": "save",
    "objective_key": null,
    "created_at": "2026-08-30T13:05:00+00:00"
  },
  "attempt": {
    "id": "uuid",
    "started_at": "...",
    "ended_at": null,
    "score": 120,
    "status": "in_progress"
  },
  "progress": {
    "save": { "scene": "hangar" },
    "summary": { "label": "Hangar 2 · 3/8 circuits", "pct": 37.5, "score": 120 },
    "score": 120,
    "best_score": 120,
    "completed_objective_keys": ["tutorial"],
    "updated_at": "...",
    "resumed": true
  }
}
```

Await save POSTs that happen at checkpoints. Heartbeats may be fire-and-forget.

---

## 7. `POST /api/coding/complete`

Call this when the student finishes a run (win, lose, or quit-and-submit). Safe to call more than once. **Does not delete the save.** Next Play still restores `progress.save` unless you also `reset`.

```json
{
  "score": 120,
  "save": { "scene": "victory", "circuit": 8 },
  "summary": {
    "label": "Complete",
    "pct": 100,
    "score": 120,
    "milestones": ["tutorial", "victory"]
  }
}
```

| Field | Required | Notes |
|---|---|---|
| `save` | no | Same full snapshot as `/events`. Send it if the end state should be restorable. |
| `summary` | no | Same as save summary. |
| `score` | no | Integer. School keeps `max(current, score)`. |
| `objectives` | no | Extra milestone keys (`objective_keys` alias). |
| `raw` | no | Merged into attempt telemetry; not used for resume. Put resume data in `save`. |

### Response

```json
{
  "ok": true,
  "attempt": {
    "id": "uuid",
    "started_at": "...",
    "ended_at": "2026-08-30T13:20:00+00:00",
    "score": 120,
    "status": "completed"
  },
  "progress": {
    "save": { "scene": "victory" },
    "summary": { "label": "Complete", "pct": 100, "score": 120 },
    "score": 120,
    "best_score": 120,
    "completed_objective_keys": ["tutorial", "victory"],
    "updated_at": "...",
    "resumed": true
  }
}
```

---

## 8. Drop-in client (copy into the game)

```js
const SCHOOL_API = "https://www.stetsonacademy.com/api/coding";

function getSessionToken() {
  return new URLSearchParams(window.location.search).get("session");
}

async function schoolFetch(path, options = {}) {
  const session = getSessionToken();
  if (!session) throw new Error("missing session");
  const res = await fetch(SCHOOL_API + path, {
    ...options,
    headers: {
      Authorization: "Bearer " + session,
      "Content-Type": "application/json",
      ...(options.headers || {}),
    },
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok || data.ok === false) {
    const err = new Error(data.error || res.statusText);
    err.status = res.status;
    throw err;
  }
  return data;
}

async function loadSchoolSession() {
  return schoolFetch("/session");
}

/** Full snapshot the next session will restore. Replace, don't patch. */
async function saveProgress(save, summary = {}) {
  return schoolFetch("/events", {
    method: "POST",
    body: JSON.stringify({ event_type: "save", save, summary }),
  });
}

async function resetProgress() {
  return schoolFetch("/events", {
    method: "POST",
    body: JSON.stringify({ event_type: "reset" }),
  });
}

async function completeRun({ score, save, summary = {} } = {}) {
  return schoolFetch("/complete", {
    method: "POST",
    body: JSON.stringify({ score, save, summary }),
  });
}

function debounce(fn, ms) {
  let t;
  return (...args) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), ms);
  };
}

// Typical boot
(async () => {
  const boot = await loadSchoolSession();
  const name = boot.student.display_name || boot.student.first_name || "Student";
  if (boot.progress && boot.progress.save) {
    restoreGame(boot.progress.save); // you write restoreGame
  } else {
    startNewGame();
  }
  const persist = debounce(() => {
    saveProgress(snapshotGame(), summarizeGame());
  }, 10000);
  // call persist() after checkpoints; also:
  document.addEventListener("visibilitychange", () => {
    if (document.hidden) saveProgress(snapshotGame(), summarizeGame());
  });
})();
```

`snapshotGame()` / `restoreGame()` / `summarizeGame()` are yours. Example summary:

```js
function summarizeGame() {
  return {
    label: `Hangar ${hangar} · ${circuits}/8 circuits`,
    pct: Math.round((circuits / 8) * 100),
    score: circuits * 40,
    milestones: flags.filter(Boolean),
  };
}
```

Suggested flow:

1. Boot → `loadSchoolSession()`.
2. If `progress.save` → restore; else new game.
3. On checkpoints / debounce / tab hide → `saveProgress(snapshot, summary)`.
4. Optional “New game” → `resetProgress()` then start over.
5. End screen → `completeRun({ score, save, summary })`.

---

## 9. What to send staff before publish

1. **Play URL** — full grok.me or custom host URL.
2. **Origin** — `https://host` with no path (from `location.origin` in the live game).
3. **Slug suggestion** — short, lowercase, hyphens (`helix`).
4. **What `summary.label` will look like** — so teachers know how to read the progress table.
5. **Unlock rules** (if any) — e.g. “requires Helix `milestones` to include `victory`”. You do **not** send a max-score table unless a later game truly gates on a number.

Staff enter Play URL / origin / optional gates on **Teacher → Games**. They can **Preview** a draft from that page. Students only see the game after **Published**. CORS origin must still match the live host.

The HTTP API path is **`/api/coding`** (stable). **`/api/games`** is the same API if you prefer that URL.

---

## 10. Builder checklist

- [ ] Game runs only after `?session=` is present; otherwise show “open from the school portal”.
- [ ] Token is read once, kept in memory, sent as `Authorization: Bearer …` on every school call.
- [ ] Token is never logged, never put in `localStorage` on a shared computer if avoidable, never sent off stetsonacademy.com.
- [ ] `GET /session` runs before play.
- [ ] `boot.preview === true` is treated as a normal session (optional Preview badge). Save/restore still run.
- [ ] If `progress.save` is an object, gameplay resumes from it.
- [ ] Full `save` snapshots are POSTed with `event_type: "save"` (replace, not merge).
- [ ] Each save includes a `summary` with at least `label` (and `pct` when it makes sense).
- [ ] Save also runs on tab hide / checkpoint, not only at the end.
- [ ] Real progress is not stored only in `localStorage`.
- [ ] `save` stays under 64 KB.
- [ ] End screen POSTs `/complete` with the latest `save` + `summary`. Completing does not wipe resume data.
- [ ] “New game” (if you have one) POSTs `event_type: "reset"`.
- [ ] UI uses first name / display name only.
- [ ] CORS origin sent to staff matches the live host.
- [ ] Rebuild that changes the grok.me host is followed by an origin update on the school site.
- [ ] No school cookies; no `credentials: "include"`.
- [ ] 401 → tell the student to click Play again (token expired or missing).

---

## 11. Local / grok.com preview

Opening the grok.me URL by itself will not work: there is no session token.

To test against the real API, a staff member plays from **stetsonacademy.com → Games → Play** (or a student account). Copy the redirected URL (it includes `session=`) into the game preview if needed. Tokens expire after 4 hours. After you save once, click Play again — the game must restore from `progress.save`.

Do not build a fake login. Do not hard-code a token in the shipped game.
