Update shell
This commit is contained in:
207
DESIGN.md
207
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.
|
- **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
|
### 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
|
- **Shielder** — Has a directional shield, must be hit from behind or above
|
||||||
- **Boss** — Large, multi-phase encounters. One per world area.
|
- **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
|
7. **Space Freighter** — Normal gravity, tight corridors, turret enemies
|
||||||
8. **Ice World** — Normal gravity, strong winds, slippery surface
|
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
|
## 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
|
## Reference Games
|
||||||
- Jazz Jackrabbit 2 (movement feel, weapon variety, level design)
|
- Jazz Jackrabbit 2 (movement feel, weapon variety, level design)
|
||||||
- Metal Slug (run-and-gun, enemy variety, visual flair)
|
- Metal Slug (run-and-gun, enemy variety, visual flair)
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -13,7 +13,7 @@ ifdef WASM
|
|||||||
-sSDL2_IMAGE_FORMATS='["png"]' \
|
-sSDL2_IMAGE_FORMATS='["png"]' \
|
||||||
-sSDL2_MIXER_FORMATS='["ogg"]' \
|
-sSDL2_MIXER_FORMATS='["ogg"]' \
|
||||||
-sALLOW_MEMORY_GROWTH=1 \
|
-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"]' \
|
-sEXPORTED_RUNTIME_METHODS='["UTF8ToString","stringToUTF8","lengthBytesUTF8"]' \
|
||||||
--preload-file assets \
|
--preload-file assets \
|
||||||
--shell-file web/shell.html
|
--shell-file web/shell.html
|
||||||
|
|||||||
9
TODO.md
9
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.
|
- `s_mars_depth` and `s_station_depth` reset when game loops back to beginning.
|
||||||
- Added `gen_bg_decoration()` call to Mars Base generator.
|
- Added `gen_bg_decoration()` call to Mars Base generator.
|
||||||
|
|
||||||
## Editor: level select should also load into game
|
## ~~Editor: level select should also load into game~~ ✓
|
||||||
In showEditor mode, the Open level-select dialog currently only loads the
|
Implemented: shell dropdown calls `game_load_level()` (exported from `main.c`)
|
||||||
selected level into the editor. It should also load the level into the game
|
which defers a level load into MODE_PLAY on the next frame. Tears down the
|
||||||
(MODE_PLAY) so test-playing reflects the newly opened file.
|
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 atmosphere particles
|
||||||
Mars Surface levels should have ambient floating particles (dust motes, fine
|
Mars Surface levels should have ambient floating particles (dust motes, fine
|
||||||
|
|||||||
50
src/main.c
50
src/main.c
@@ -50,6 +50,23 @@ static int s_mars_depth = 0;
|
|||||||
#define PAUSE_ITEM_COUNT 3
|
#define PAUSE_ITEM_COUNT 3
|
||||||
static int s_pause_selection = 0; /* 0=Resume, 1=Restart, 2=Quit */
|
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) {
|
static const char *theme_name(LevelTheme t) {
|
||||||
switch (t) {
|
switch (t) {
|
||||||
case THEME_PLANET_SURFACE: return "Planet Surface";
|
case THEME_PLANET_SURFACE: return "Planet Surface";
|
||||||
@@ -343,6 +360,39 @@ static void pause_update(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void game_update(float dt) {
|
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) {
|
if (s_mode == MODE_EDITOR) {
|
||||||
editor_update(&s_editor, dt);
|
editor_update(&s_editor, dt);
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
<button class="ctrl-btn" id="btn-save" title="Save level (download .lvl)">Save</button>
|
<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>
|
<button class="ctrl-btn" id="btn-load" title="Load .lvl from disk">Load</button>
|
||||||
<span class="ctrl-sep">|</span>
|
<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>
|
<option value="">-- Open level --</option>
|
||||||
</select>
|
</select>
|
||||||
<span class="ctrl-sep">|</span>
|
<span class="ctrl-sep">|</span>
|
||||||
@@ -263,12 +263,12 @@
|
|||||||
var path = this.value;
|
var path = this.value;
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
|
|
||||||
if (typeof _editor_load_vfs_file === 'function') {
|
/* Load the level into gameplay (MODE_PLAY) via main.c */
|
||||||
/* Pass the path string to C */
|
if (typeof _game_load_level === 'function') {
|
||||||
var len = lengthBytesUTF8(path) + 1;
|
var len = lengthBytesUTF8(path) + 1;
|
||||||
var buf = _malloc(len);
|
var buf = _malloc(len);
|
||||||
stringToUTF8(path, buf, len);
|
stringToUTF8(path, buf, len);
|
||||||
_editor_load_vfs_file(buf);
|
_game_load_level(buf);
|
||||||
_free(buf);
|
_free(buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user