# Stetson Academy coding-game builder guide

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

Share this file with anyone building a game on grok.com / grok.me for Stetson Academy.

The game is **hosted on grok.me**. Stetson Academy **starts the session, decides who may play, and stores scores**. The game must not invent its own login.

**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.

Do **not** require the student to type a password inside the game. Do **not** send the token to any other host.

### 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 published game.

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://abc123.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 |
| `POST` | `/events` | When an objective is earned, or for progress / score / heartbeat |
| `POST` | `/complete` | When the student finishes this run |
| `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` on `/events` |
| 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. `GET /api/coding/session`

Call this first. Use the returned `game.objectives` as the source of truth for keys and points. Do not hard-code keys that staff did not register.

### 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": "rescue-robot",
    "title": "Rescue Robot",
    "description": "",
    "max_score": 100,
    "objectives": [
      { "key": "loop", "title": "Write a loop", "points": 40, "required": true }
    ]
  },
  "attempt": {
    "id": "uuid",
    "started_at": "2026-08-30T13:00:00+00:00",
    "ended_at": null,
    "score": 0,
    "status": "in_progress"
  },
  "assignment": {
    "id": "uuid",
    "starts_at": null,
    "due_at": "2026-09-15T16:00:00-07:00"
  },
  "progress": {
    "best_score": 80,
    "completed_objective_keys": ["loop"]
  }
}
```

`assignment` is `null` when the game is not tied to a class window.

### 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.

Use `progress.completed_objective_keys` and `progress.best_score` to restore state if the student already played.

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

---

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

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

```json
{
  "event_type": "objective",
  "objective_key": "loop",
  "payload": { "level": 1 }
}
```

### `event_type` values the school understands

| `event_type` | Also accepted | Effect |
|---|---|---|
| `objective` | `objective_complete`, `complete_objective`, `unlock` | Records `objective_key`. If that key is on the game, adds its points (score never decreases). |
| `score` | `progress` | 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). |
| `heartbeat` | anything else | Stored for teachers; no score change unless `payload.score` is set. |

`objective_key` aliases: `objective`.

`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": "objective",
    "objective_key": "loop",
    "created_at": "2026-08-30T13:05:00+00:00"
  },
  "attempt": {
    "id": "uuid",
    "started_at": "...",
    "ended_at": null,
    "score": 40,
    "status": "in_progress"
  }
}
```

Report each objective **once per run** when the student first earns it. Duplicate keys are stored but points are not added twice for scoring from the catalog.

You may keep sending events after `/complete` (telemetry). Prefer finishing with `/complete`.

---

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

Call this when the student finishes (win, lose, or quit-and-submit). Safe to call more than once; score only moves **up**.

```json
{
  "score": 90,
  "objectives": ["loop", "debug"],
  "raw": { "stars": 3, "time_ms": 184000 }
}
```

| Field | Required | Notes |
|---|---|---|
| `score` | no | Integer. School keeps `max(current, score)`, capped only by how staff configured the game. |
| `objectives` | no | Array of objective keys earned this run (`objective_keys` alias). Keys not already recorded are stored as `objective_complete`. |
| `raw` | no | Object of extra stats for teachers (stars, time, level). Merged into the attempt. |

If `score` is omitted, the school may set score from the sum of earned objective points, capped at `game.max_score`.

### Response

```json
{
  "ok": true,
  "attempt": {
    "id": "uuid",
    "started_at": "...",
    "ended_at": "2026-08-30T13:20:00+00:00",
    "score": 90,
    "status": "completed"
  }
}
```

---

## 7. Objective keys

Staff register keys on the school site (example: `loop`, `conditionals`, `debug`). The game **must use those exact strings**.

- Keys are case-sensitive.
- Do not send titles (“Write a loop”) as keys.
- Read keys from `GET /session` → `game.objectives[].key`.
- If you add a new objective in the game, tell staff the key, title, points, and whether it is required **before** publish.

Gates on later games can require “student completed objective `loop` on game X”. If the key never arrives in `/events` or `/complete`, the next game stays locked.

---

## 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");
}

async function reportObjective(key, payload = {}) {
  return schoolFetch("/events", {
    method: "POST",
    body: JSON.stringify({
      event_type: "objective",
      objective_key: key,
      payload,
    }),
  });
}

async function reportScore(score, payload = {}) {
  return schoolFetch("/events", {
    method: "POST",
    body: JSON.stringify({
      event_type: "score",
      payload: { ...payload, score },
    }),
  });
}

async function completeRun({ score, objectives = [], raw = {} } = {}) {
  return schoolFetch("/complete", {
    method: "POST",
    body: JSON.stringify({ score, objectives, raw }),
  });
}

// Typical boot
(async () => {
  const boot = await loadSchoolSession();
  const name = boot.student.display_name || boot.student.first_name || "Student";
  // greet with first name only
  // unlock UI from boot.game.objectives and boot.progress.completed_objective_keys
})();
```

Suggested flow:

1. Boot → `loadSchoolSession()`.
2. Each milestone → `reportObjective("the_key")`.
3. Optional mid-run → `reportScore(n)`.
4. End screen → `completeRun({ score, objectives: [...earnedKeys], raw })`.

Fire-and-forget is fine for heartbeat; for objectives and complete, `await` and surface failures to the student.

---

## 9. What to send staff before publish

1. **Play URL** — full grok.me URL.
2. **Origin** — `https://host` with no path (from `location.origin` in the live game).
3. **Slug suggestion** — short, lowercase, hyphens (`rescue-robot`).
4. **Objective list** — for each: `key`, title, points, required yes/no.
5. **Max score** — usually the sum of points, or 100.
6. **Unlock rules** (if any) — e.g. “requires `intro-game` completed” or “requires objective `loop` and score ≥ 50”.

Staff enter those on **Teacher → Coding Games**. Until the game is **Published** and origin matches, Play will 404 and API calls will fail CORS or 403.

---

## 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.
- [ ] Objective keys match staff keys exactly; keys come from `game.objectives`.
- [ ] Each earned objective is POSTed to `/events` with `event_type: "objective"`.
- [ ] Run end POSTs `/complete` with `score` and `objectives`.
- [ ] UI uses first name / display name only.
- [ ] CORS origin sent to staff matches the live grok.me 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 → Coding Games → Play** (or a student account). Copy the redirected URL (it includes `session=`) into the game preview if needed. Tokens expire after 4 hours.

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