diff --git a/DESIGN.md b/DESIGN.md
index c8ad2a3..c9d8a62 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -83,6 +83,16 @@ Already implemented: `GRAVITY`, `WIND`, `BG_COLOR`, `MUSIC`, `PARALLAX_FAR`, `PA
- **Laser Turret** — State machine (IDLE → CHARGING → FIRING → COOLDOWN). Per-pixel beam raycast. Fixed variant aims left; tracking variant rotates toward player at 1.5 rad/s.
### Planned
+- **Robot** — Slow, heavy ground patrol. Sturdy (4 HP), armored appearance.
+ Walks deliberately, doesn't flinch from knockback. Punishes careless
+ approaches — player must keep distance or use high-damage weapons.
+ Exclusive to space station levels.
+- **Rocket Turret** — Stationary launcher, two-stage attack. Stage 1: rocket
+ plops up out of the turret with a visible arc (telegraph, ~0.6 s hang time),
+ giving the player time to react. Stage 2: rocket ignites boosters and tracks
+ the player with homing guidance. Moderate turn rate so skilled players can
+ dodge or bait it into walls. Destroyable in flight. Exclusive to space
+ station levels.
- **Shielder** — Has a directional shield, must be hit from behind or above
- **Boss** — Large, multi-phase encounters. One per world area.
@@ -153,6 +163,32 @@ Current directives: `TILESET`, `SIZE`, `SPAWN`, `GRAVITY`, `WIND`, `BG_COLOR`, `
7. **Space Freighter** — Normal gravity, tight corridors, turret enemies
8. **Ice World** — Normal gravity, strong winds, slippery surface
+### Old Space Station Campaign
+
+Abandoned orbital station overrun by malfunctioning security systems. Only
+robotic enemies remain — no organic creatures. Cold, industrial aesthetic
+with metal walls, exposed pipes, warning lights, and airlock doors.
+
+**Enemy roster (station-exclusive):**
+- Robots — slow, heavy patrols that absorb punishment
+- Turrets — standard rotating turrets from earlier levels
+- Rocket Turrets — two-stage homing rockets (plop-up telegraph → boost + track)
+
+**Level sequence:**
+- **station01.lvl** (Docking Bay) — Spacecraft lands at the station exterior.
+ Normal gravity, wide open hangar area easing the player in. A few robots
+ and a turret introduce the new enemy types. Exit leads inside.
+- **station02.lvl** (Reactor Core) — Vertical level, tight corridors around
+ a central reactor shaft. Rocket turrets cover long sightlines, robots
+ block narrow passages. Elevator transition into generated levels.
+- **generate:old_station** (Security Decks) — Procedurally generated
+ interior levels (2-3 before the boss arena). Increasing density of
+ robots, turrets, and rocket turrets. Tight rooms, low ceilings,
+ interlocking corridors. Difficulty scales with depth.
+- **station03.lvl** (Command Bridge / Boss Arena) — Final station level.
+ Large arena with a boss encounter (station security chief or haywire
+ defense mainframe). Heavy use of rocket turrets as stage hazards.
+
---
## World Map
@@ -231,6 +267,177 @@ Current directives: `TILESET`, `SIZE`, `SPAWN`, `GRAVITY`, `WIND`, `BG_COLOR`, `
---
+## Game Analytics & Highscores
+
+Track comprehensive play session analytics and submit them to a backend service
+for leaderboards and gameplay insights. Data flows from C → JS (via EM_JS) →
+backend API (via fetch). Desktop builds can write to a local file as fallback.
+
+### Metrics to track (GameStats struct)
+
+**Per-run (reset on new game / restart):**
+
+| Metric | Type | Notes |
+|-------------------------|----------|---------------------------------------------|
+| `levels_completed` | int | Incremented on each level exit trigger |
+| `enemies_killed` | int | Total kills across all levels |
+| `kills_by_type[N]` | int[] | Kills broken down by enemy type |
+| `deaths` | int | Player death/respawn count |
+| `time_elapsed_ms` | uint32_t | Wall-clock play time (accumulate dt) |
+| `shots_fired` | int | Total projectiles spawned by player |
+| `shots_hit` | int | Player projectiles that connected with enemy |
+| `damage_taken` | int | Total HP lost (before death resets) |
+| `damage_dealt` | int | Total HP dealt to enemies |
+| `dashes_used` | int | Dash activations |
+| `jumps` | int | Jump count |
+| `distance_traveled` | float | Horizontal pixels traversed |
+| `pickups_collected` | int | Health, jetpack, weapon pickups |
+| `longest_kill_streak` | int | Max kills without taking damage |
+| `current_kill_streak` | int | (internal, not submitted) |
+
+**Per-level snapshot (ring buffer or array, flushed on level exit):**
+- Level name / generator tag
+- Time spent in level
+- Kills in level
+- Deaths in level
+- Health remaining on exit
+
+### Data flow
+
+```
+C (GameStats) JS (shell.html) Backend API
+ │ │ │
+ ├─ on victory/game-over ────→│ │
+ │ EM_JS: submit_run() ├── POST /api/runs ────────→│
+ │ │ { stats JSON } │── store in DB
+ │ │ │
+ ├─ on leaderboard open ─────→│ │
+ │ EM_JS: fetch_leaderboard()├── GET /api/leaderboard ──→│
+ │ │←─ JSON [ top N runs ] ─────│
+ │←─ KEEPALIVE callback ──────│ │
+ │ write to C memory │ │
+```
+
+### Backend service
+
+Small HTTP API deployed alongside the game on k3s. Receives run data as
+JSON, stores in a lightweight DB (SQLite or Postgres), serves leaderboard
+queries. Endpoints:
+
+- `POST /api/runs` — submit a completed run (all metrics above)
+- `GET /api/leaderboard` — top N runs, sortable by score/time/kills
+- `GET /api/stats` — aggregate stats (total runs, total kills, avg time)
+
+A composite score formula ranks runs for the leaderboard, e.g.:
+`score = (enemies_killed * 100) + (levels_completed * 500) - (deaths * 200) - (time_elapsed_ms / 1000)`
+
+### Integration points in C
+
+- **GameStats struct** in `main.c` or a new `src/game/stats.h` module
+- **Kill counting:** hook into entity death in `level.c` damage handling
+- **Shot tracking:** increment in `player.c` shoot logic
+- **Time tracking:** accumulate `dt` each frame in `MODE_PLAY`
+- **Distance:** accumulate abs(vel.x * dt) each frame
+- **Jumps/dashes:** increment in `player.c` at point of activation
+- **Level snapshots:** capture on exit trigger before level_free
+- **Submission:** call `EM_JS` function from the victory path in `main.c`
+
+### Anti-tamper: HMAC signature
+
+All submissions are signed client-side in C/WASM before reaching JS,
+preventing casual forgery (console injection, proxy interception, modified
+payloads). The signature lives in compiled WASM — not trivially visible
+like plain JS — raising the effort required to cheat.
+
+**Scheme: HMAC-SHA256**
+
+1. A shared secret key is embedded in the C source (compiled into WASM).
+ Not truly hidden from a determined reverse-engineer, but opaque to
+ casual inspection.
+2. On submission, the C code:
+ - Serializes the `GameStats` struct to a canonical JSON string
+ - Requests a one-time nonce from the backend (`GET /api/nonce`)
+ - Computes `HMAC-SHA256(secret, nonce + json_payload)`
+ - Passes the JSON, nonce, and hex signature to JS via `EM_JS`
+3. JS sends all three to the backend in the POST body.
+4. Backend recomputes the HMAC with its copy of the secret, verifies the
+ signature matches, and checks the nonce hasn't been used before (replay
+ protection).
+
+**What this prevents:**
+- Browser console `fetch("/api/runs", { body: fakeStats })` — no valid sig
+- Proxy/MITM payload modification — signature won't match
+- Replay attacks — nonce is single-use
+
+**What this does NOT prevent:**
+- Reverse-engineering the WASM binary to extract the key
+- Memory editing during gameplay (modifying stats before signing)
+- A fully custom WASM build that signs fabricated data
+
+**Implementation:**
+- SHA-256 and HMAC can be implemented in ~200 lines of C (no external
+ dependency). Alternatively use a small embedded library like micro-ecc
+ or TweetNaCl.
+- The secret key should be split across multiple static arrays and
+ reassembled at runtime to deter simple string scanning of the binary.
+- Native builds (Linux/Windows) use the same HMAC logic, posting via
+ libcurl or a lightweight HTTP client.
+
+### Anti-tamper: server-side plausibility checks
+
+The backend validates every submission against known game constraints.
+This is independent of the HMAC and catches cheats that produce valid
+signatures (memory edits, custom builds).
+
+**Hard limits (reject immediately):**
+- `levels_completed` cannot exceed the total campaign length
+- `enemies_killed` cannot exceed max possible spawns per level chain
+- `shots_hit` cannot exceed `shots_fired`
+- `damage_dealt` cannot exceed `enemies_killed * max_enemy_hp`
+- `accuracy` (`shots_hit / shots_fired`) above 99% is flagged
+- `time_elapsed_ms` below a minimum threshold per level is impossible
+ (speed-of-light check: level_width / max_player_speed)
+
+**Soft limits (flag for review, don't reject):**
+- Zero deaths across the entire campaign
+- Kill streak equal to total kills (never took damage)
+- Unusually low time relative to levels completed
+- Distance traveled below expected minimum for the level chain
+
+**Rate limiting:**
+- Max 1 submission per IP per 60 seconds
+- Max 10 submissions per IP per hour
+- Duplicate payload detection (exact same stats = reject)
+
+**Schema:**
+```
+runs table:
+ id, player_name, submitted_at, ip_hash,
+ signature_valid (bool), plausibility_flags (bitmask),
+ levels_completed, enemies_killed, kills_by_type (json),
+ deaths, time_elapsed_ms, shots_fired, shots_hit,
+ damage_taken, damage_dealt, dashes_used, jumps,
+ distance_traveled, pickups_collected, longest_kill_streak,
+ level_snapshots (json), composite_score
+```
+
+Flagged runs are stored but excluded from the public leaderboard.
+
+### Requirements before this works
+
+1. The game needs an ending — either a final boss level with empty exit
+ target, or a depth limit on the station generator
+2. A `MODE_VICTORY` game state showing final stats + leaderboard
+3. The backend service (container image + k8s manifests)
+4. Player name input (simple text prompt in JS, or anonymous with a
+ generated handle)
+5. HMAC-SHA256 implementation in C (or vendored micro-library)
+6. Nonce endpoint on the backend
+7. Plausibility rule set derived from game constants (MAX_ENTITIES,
+ level chain length, player speed, enemy HP values)
+
+---
+
## Reference Games
- Jazz Jackrabbit 2 (movement feel, weapon variety, level design)
- Metal Slug (run-and-gun, enemy variety, visual flair)
diff --git a/Makefile b/Makefile
index c0949aa..2ac4c47 100644
--- a/Makefile
+++ b/Makefile
@@ -13,7 +13,7 @@ ifdef WASM
-sSDL2_IMAGE_FORMATS='["png"]' \
-sSDL2_MIXER_FORMATS='["ogg"]' \
-sALLOW_MEMORY_GROWTH=1 \
- -sEXPORTED_FUNCTIONS='["_main","_editor_upload_flag_ptr","_editor_save_flag_ptr","_editor_load_flag_ptr","_editor_load_vfs_file","_malloc","_free"]' \
+ -sEXPORTED_FUNCTIONS='["_main","_editor_upload_flag_ptr","_editor_save_flag_ptr","_editor_load_flag_ptr","_editor_load_vfs_file","_game_load_level","_malloc","_free"]' \
-sEXPORTED_RUNTIME_METHODS='["UTF8ToString","stringToUTF8","lengthBytesUTF8"]' \
--preload-file assets \
--shell-file web/shell.html
diff --git a/TODO.md b/TODO.md
index 7f228ed..d965c09 100644
--- a/TODO.md
+++ b/TODO.md
@@ -139,10 +139,11 @@ Mars themes in all generic segment generators.
- `s_mars_depth` and `s_station_depth` reset when game loops back to beginning.
- Added `gen_bg_decoration()` call to Mars Base generator.
-## Editor: level select should also load into game
-In showEditor mode, the Open level-select dialog currently only loads the
-selected level into the editor. It should also load the level into the game
-(MODE_PLAY) so test-playing reflects the newly opened file.
+## ~~Editor: level select should also load into game~~ ✓
+Implemented: shell dropdown calls `game_load_level()` (exported from `main.c`)
+which defers a level load into MODE_PLAY on the next frame. Tears down the
+current mode (editor or play), loads the selected `.lvl` file, and seeds
+`s_edit_path` so pressing E opens the same level in the editor.
## Mars Surface atmosphere particles
Mars Surface levels should have ambient floating particles (dust motes, fine
diff --git a/src/main.c b/src/main.c
index 0181eb6..277d871 100644
--- a/src/main.c
+++ b/src/main.c
@@ -50,6 +50,23 @@ static int s_mars_depth = 0;
#define PAUSE_ITEM_COUNT 3
static int s_pause_selection = 0; /* 0=Resume, 1=Restart, 2=Quit */
+#ifdef __EMSCRIPTEN__
+/* JS-initiated level load request (level-select dropdown in shell). */
+static int s_js_load_request = 0;
+static char s_js_load_path[ASSET_PATH_MAX] = {0};
+#endif
+
+#ifdef __EMSCRIPTEN__
+/* Called from the JS shell level-select dropdown to load a level into
+ * gameplay mode. Sets a deferred request that game_update() picks up on
+ * the next frame so we don't mutate game state from an arbitrary call site. */
+EMSCRIPTEN_KEEPALIVE
+void game_load_level(const char *path) {
+ snprintf(s_js_load_path, sizeof(s_js_load_path), "%s", path);
+ s_js_load_request = 1;
+}
+#endif
+
static const char *theme_name(LevelTheme t) {
switch (t) {
case THEME_PLANET_SURFACE: return "Planet Surface";
@@ -343,6 +360,39 @@ static void pause_update(void) {
}
static void game_update(float dt) {
+#ifdef __EMSCRIPTEN__
+ /* Handle deferred level load from JS shell dropdown. */
+ if (s_js_load_request && s_js_load_path[0]) {
+ s_js_load_request = 0;
+
+ /* Tear down whatever mode we are in. */
+ if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED) {
+ level_free(&s_level);
+ } else if (s_mode == MODE_EDITOR) {
+ editor_free(&s_editor);
+ }
+
+ s_mode = MODE_PLAY;
+ s_testing_from_editor = false;
+
+ if (!load_level_file(s_js_load_path)) {
+ fprintf(stderr, "Failed to load level from shell: %s\n",
+ s_js_load_path);
+ /* Fall back to the first campaign level. */
+ if (!load_level_file("assets/levels/moon01.lvl")) {
+ g_engine.running = false;
+ }
+ }
+
+ /* Also seed the editor path so pressing E opens this level. */
+ snprintf(s_edit_path, sizeof(s_edit_path), "%s", s_js_load_path);
+ s_js_load_path[0] = '\0';
+
+ SDL_SetWindowTitle(g_engine.window, "Jump 'n Run");
+ return;
+ }
+#endif
+
if (s_mode == MODE_EDITOR) {
editor_update(&s_editor, dt);
diff --git a/web/shell.html b/web/shell.html
index db7625d..6fa3b9e 100644
--- a/web/shell.html
+++ b/web/shell.html
@@ -117,7 +117,7 @@
|
-