Update shell

This commit is contained in:
Thomas
2026-03-05 17:38:46 +00:00
parent b54a53b9c8
commit 635869f226
5 changed files with 267 additions and 9 deletions

207
DESIGN.md
View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -117,7 +117,7 @@
<button class="ctrl-btn" id="btn-save" title="Save level (download .lvl)">Save</button>
<button class="ctrl-btn" id="btn-load" title="Load .lvl from disk">Load</button>
<span class="ctrl-sep">|</span>
<select id="level-select" title="Open a built-in level in the editor">
<select id="level-select" title="Load a built-in level">
<option value="">-- Open level --</option>
</select>
<span class="ctrl-sep">|</span>
@@ -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);
}