From 635869f22643c87be34d8981cf9e593dde48c037 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 5 Mar 2026 17:38:46 +0000 Subject: [PATCH] Update shell --- DESIGN.md | 207 +++++++++++++++++++++++++++++++++++++++++++++++++ Makefile | 2 +- TODO.md | 9 ++- src/main.c | 50 ++++++++++++ web/shell.html | 8 +- 5 files changed, 267 insertions(+), 9 deletions(-) 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 @@ | - | @@ -263,12 +263,12 @@ var path = this.value; if (!path) return; - if (typeof _editor_load_vfs_file === 'function') { - /* Pass the path string to C */ + /* Load the level into gameplay (MODE_PLAY) via main.c */ + if (typeof _game_load_level === 'function') { var len = lengthBytesUTF8(path) + 1; var buf = _malloc(len); stringToUTF8(path, buf, len); - _editor_load_vfs_file(buf); + _game_load_level(buf); _free(buf); }