forked from tas/major_tom
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.
|
||||
|
||||
### 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)
|
||||
|
||||
2
Makefile
2
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
|
||||
|
||||
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.
|
||||
- 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
|
||||
|
||||
50
src/main.c
50
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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user