From a23ecaf4c15a61f1f6f4d03f39e6da22ee7ee572 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Mar 2026 17:11:39 +0000 Subject: [PATCH 1/2] Add analytics integration with Horchposten backend Implements session-based analytics tracking that sends gameplay stats to the Horchposten API. Adds stats.{c,h} for accumulating per-session metrics (kills, deaths, shots, dashes, jumps, pickups, damage, time) and analytics.{c,h} with EM_JS bridge for fetch() calls to the backend. Client ID is persisted in localStorage. Session start/end hooks are wired into all game lifecycle events (level transitions, restart, quit, tab close via sendBeacon). Analytics URL/key are configured via data attributes on the canvas container. Non-WASM builds compile with no-op stubs. Co-Authored-By: Claude Opus 4.6 --- src/game/analytics.c | 159 +++++++++++++++++++++++++++++++++++++++++++ src/game/analytics.h | 17 +++++ src/game/level.c | 7 ++ src/game/player.c | 4 ++ src/game/stats.c | 29 ++++++++ src/game/stats.h | 45 ++++++++++++ src/main.c | 46 +++++++++++++ web/shell.html | 25 ++++++- 8 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 src/game/analytics.c create mode 100644 src/game/analytics.h create mode 100644 src/game/stats.c create mode 100644 src/game/stats.h diff --git a/src/game/analytics.c b/src/game/analytics.c new file mode 100644 index 0000000..9ab6cd7 --- /dev/null +++ b/src/game/analytics.c @@ -0,0 +1,159 @@ +#include "game/analytics.h" +#include + +#ifdef __EMSCRIPTEN__ +#include + +/* ── EM_JS bridge: JavaScript functions callable from C ─────────── */ + +/* Initialize client_id in localStorage and store the analytics + * API URL + key. Called once at startup. */ +EM_JS(void, js_analytics_init, (), { + /* Generate or retrieve a persistent client UUID */ + if (!localStorage.getItem('jnr_client_id')) { + localStorage.setItem('jnr_client_id', crypto.randomUUID()); + } + /* Store config on the Module for later use by other EM_JS calls. + * ANALYTICS_URL and ANALYTICS_KEY are replaced at build time via + * -D defines, falling back to sensible defaults. */ + Module._analyticsClientId = localStorage.getItem('jnr_client_id'); + Module._analyticsSessionId = null; + + /* Runtime config: check for data attributes on the canvas container + * first, then fall back to compiled-in defaults. */ + var container = document.getElementById('canvas-container'); + Module._analyticsUrl = (container && container.dataset.analyticsUrl) + ? container.dataset.analyticsUrl + : (typeof ANALYTICS_URL !== 'undefined' ? ANALYTICS_URL : ''); + Module._analyticsKey = (container && container.dataset.analyticsKey) + ? container.dataset.analyticsKey + : (typeof ANALYTICS_KEY !== 'undefined' ? ANALYTICS_KEY : ''); + + if (!Module._analyticsUrl) { + console.log('[analytics] No analytics URL configured — analytics disabled'); + } else { + console.log('[analytics] Initialized, client_id=' + Module._analyticsClientId); + } +}); + +/* Start a new session. Sends POST /api/analytics/session/start/. */ +EM_JS(void, js_analytics_session_start, (), { + if (!Module._analyticsUrl) return; + + var body = JSON.stringify({ + client_id: Module._analyticsClientId, + device: { + platform: navigator.platform || '', + language: navigator.language || '', + screen_width: screen.width, + screen_height: screen.height, + device_pixel_ratio: window.devicePixelRatio || 1, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || '', + webgl_renderer: (function() { + try { + var c = document.createElement('canvas'); + var gl = c.getContext('webgl') || c.getContext('experimental-webgl'); + if (gl) { + var ext = gl.getExtension('WEBGL_debug_renderer_info'); + return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : ''; + } + } catch(e) {} + return ''; + })(), + touch_support: ('ontouchstart' in window) + } + }); + + fetch(Module._analyticsUrl + '/api/analytics/session/start/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': Module._analyticsKey + }, + body: body + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + Module._analyticsSessionId = data.session_id || null; + console.log('[analytics] Session started: ' + Module._analyticsSessionId); + }) + .catch(function(err) { + console.error('[analytics] Session start failed:', err); + }); +}); + +/* End the current session with gameplay stats. + * Parameters are passed from C as integers/string. */ +EM_JS(void, js_analytics_session_end, (int score, int level_reached, + int lives_used, int duration_secs, + const char *end_reason_ptr), { + if (!Module._analyticsUrl || !Module._analyticsSessionId) return; + + var endReason = UTF8ToString(end_reason_ptr); + var sid = Module._analyticsSessionId; + + var body = JSON.stringify({ + score: score, + level_reached: level_reached > 0 ? level_reached : 1, + lives_used: lives_used, + duration_seconds: duration_secs, + end_reason: endReason + }); + + fetch(Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': Module._analyticsKey + }, + body: body + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + console.log('[analytics] Session ended: ' + sid + + (data.new_high_score ? ' (NEW HIGH SCORE!)' : '')); + }) + .catch(function(err) { + console.error('[analytics] Session end failed:', err); + }); + + /* Clear session so duplicate end calls are harmless */ + Module._analyticsSessionId = null; +}); + +/* ── C wrappers ─────────────────────────────────────────────────── */ + +void analytics_init(void) { + js_analytics_init(); +} + +void analytics_session_start(void) { + js_analytics_session_start(); +} + +void analytics_session_end(const GameStats *stats, const char *end_reason) { + stats_update_score((GameStats *)stats); + js_analytics_session_end( + stats->score, + stats->levels_completed > 0 ? stats->levels_completed : 1, + stats->deaths, + (int)stats->time_elapsed, + end_reason + ); +} + +#else +/* ── Non-WASM stubs ─────────────────────────────────────────────── */ + +void analytics_init(void) { + printf("[analytics] Analytics disabled (native build)\n"); +} + +void analytics_session_start(void) {} + +void analytics_session_end(const GameStats *stats, const char *end_reason) { + (void)stats; + (void)end_reason; +} + +#endif /* __EMSCRIPTEN__ */ diff --git a/src/game/analytics.h b/src/game/analytics.h new file mode 100644 index 0000000..1d514f8 --- /dev/null +++ b/src/game/analytics.h @@ -0,0 +1,17 @@ +#ifndef JNR_ANALYTICS_H +#define JNR_ANALYTICS_H + +#include "game/stats.h" + +/* Initialize analytics subsystem (load/generate client_id). + * No-op on non-WASM builds. */ +void analytics_init(void); + +/* Start a new analytics session. Sends POST to the backend. */ +void analytics_session_start(void); + +/* End the current analytics session with final stats. + * end_reason: "death", "quit", "timeout", or "completed". */ +void analytics_session_end(const GameStats *stats, const char *end_reason); + +#endif /* JNR_ANALYTICS_H */ diff --git a/src/game/level.c b/src/game/level.c index 2723fdb..7a12d5c 100644 --- a/src/game/level.c +++ b/src/game/level.c @@ -8,6 +8,7 @@ #include "game/spacecraft.h" #include "game/sprites.h" #include "game/entity_registry.h" +#include "game/stats.h" #include "engine/core.h" #include "engine/renderer.h" #include "engine/physics.h" @@ -174,9 +175,11 @@ bool level_load_generated(Level *level, Tilemap *gen_map) { static Camera *s_active_camera = NULL; static void damage_entity(Entity *target, int damage) { + stats_record_damage_dealt(damage); target->health -= damage; if (target->health <= 0) { target->flags |= ENTITY_DEAD; + stats_record_kill(); /* Death particles — centered on entity */ Vec2 center = vec2( @@ -210,6 +213,7 @@ static void damage_entity(Entity *target, int damage) { static void damage_player(Entity *player, int damage, Entity *source) { PlayerData *ppd = (PlayerData *)player->data; + stats_record_damage_taken(damage); damage_entity(player, damage); /* Screen shake on player hit (stronger) */ @@ -264,6 +268,7 @@ static void handle_collisions(EntityManager *em) { if (from_player && entity_is_enemy(b)) { if (physics_overlap(&a->body, &b->body)) { damage_entity(b, a->damage); + stats_record_shot_hit(); hit = true; } } @@ -366,6 +371,7 @@ static void handle_collisions(EntityManager *em) { } if (picked_up) { + stats_record_pickup(); /* Pickup particles */ Vec2 center = vec2( a->body.pos.x + a->body.size.x * 0.5f, @@ -566,6 +572,7 @@ void level_update(Level *level, float dt) { for (int i = 0; i < level->entities.count; i++) { Entity *e = &level->entities.entities[i]; if (e->active && e->type == ENT_PLAYER && player_wants_respawn(e)) { + stats_record_death(); player_respawn(e, level->map.player_spawn); Vec2 center = vec2( e->body.pos.x + e->body.size.x * 0.5f, diff --git a/src/game/player.c b/src/game/player.c index 7b7b817..0d47a68 100644 --- a/src/game/player.c +++ b/src/game/player.c @@ -1,6 +1,7 @@ #include "game/player.h" #include "game/sprites.h" #include "game/projectile.h" +#include "game/stats.h" #include "engine/input.h" #include "engine/physics.h" #include "engine/renderer.h" @@ -301,6 +302,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) { if (input_pressed(ACTION_DASH) && pd->dash_charges > 0) { pd->dash_charges--; + stats_record_dash(); /* Start recharge timer only if not already recharging */ if (pd->dash_recharge_timer <= 0) { pd->dash_recharge_timer = (pd->jetpack_boost_timer > 0) @@ -410,6 +412,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) { pd->jumping = true; pd->jump_buffer_timer = 0; pd->coyote_timer = 0; + stats_record_jump(); audio_play_sound(s_sfx_jump, 96); } @@ -460,6 +463,7 @@ void player_update(Entity *self, float dt, const Tilemap *map) { } projectile_spawn_dir(s_em, bullet_pos, shoot_dir, true); + stats_record_shot_fired(); /* Muzzle flash slightly ahead of bullet origin (at barrel tip) */ Vec2 flash_pos = vec2( bullet_pos.x + shoot_dir.x * 4.0f, diff --git a/src/game/stats.c b/src/game/stats.c new file mode 100644 index 0000000..51eb705 --- /dev/null +++ b/src/game/stats.c @@ -0,0 +1,29 @@ +#include "game/stats.h" +#include + +static GameStats *s_active = NULL; + +void stats_reset(GameStats *s) { + memset(s, 0, sizeof(GameStats)); +} + +void stats_update_score(GameStats *s) { + int score = s->levels_completed * 100 + + s->enemies_killed * 10 + - s->deaths * 25 + + s->pickups_collected * 5; + s->score = score > 0 ? score : 0; +} + +void stats_set_active(GameStats *s) { s_active = s; } +GameStats *stats_get_active(void) { return s_active; } + +void stats_record_kill(void) { if (s_active) s_active->enemies_killed++; } +void stats_record_death(void) { if (s_active) s_active->deaths++; } +void stats_record_shot_fired(void) { if (s_active) s_active->shots_fired++; } +void stats_record_shot_hit(void) { if (s_active) s_active->shots_hit++; } +void stats_record_dash(void) { if (s_active) s_active->dashes_used++; } +void stats_record_jump(void) { if (s_active) s_active->jumps++; } +void stats_record_pickup(void) { if (s_active) s_active->pickups_collected++; } +void stats_record_damage_taken(int n) { if (s_active) s_active->damage_taken += n; } +void stats_record_damage_dealt(int n) { if (s_active) s_active->damage_dealt += n; } diff --git a/src/game/stats.h b/src/game/stats.h new file mode 100644 index 0000000..b1d8cbd --- /dev/null +++ b/src/game/stats.h @@ -0,0 +1,45 @@ +#ifndef JNR_STATS_H +#define JNR_STATS_H + +#include + +/* Per-session gameplay statistics, accumulated during play and + * submitted to the analytics backend when the session ends. */ +typedef struct GameStats { + int score; /* composite score */ + int levels_completed; /* exit zone triggers */ + int enemies_killed; /* total kills */ + int deaths; /* player respawn count */ + int shots_fired; /* projectiles spawned by player */ + int shots_hit; /* player projectiles that hit */ + int dashes_used; /* dash activations */ + int jumps; /* jump count */ + int pickups_collected; /* all powerup pickups */ + int damage_taken; /* total HP lost */ + int damage_dealt; /* total HP dealt to enemies */ + float time_elapsed; /* wall-clock seconds */ +} GameStats; + +/* Reset all stats to zero (call at session start). */ +void stats_reset(GameStats *s); + +/* Recompute the composite score from raw metrics. */ +void stats_update_score(GameStats *s); + +/* Set/get the active stats instance (global pointer used by + * level.c and player.c to record events during gameplay). */ +void stats_set_active(GameStats *s); +GameStats *stats_get_active(void); + +/* Convenience: increment a stat on the active instance (no-op if NULL). */ +void stats_record_kill(void); +void stats_record_death(void); +void stats_record_shot_fired(void); +void stats_record_shot_hit(void); +void stats_record_dash(void); +void stats_record_jump(void); +void stats_record_pickup(void); +void stats_record_damage_taken(int amount); +void stats_record_damage_dealt(int amount); + +#endif /* JNR_STATS_H */ diff --git a/src/main.c b/src/main.c index 277d871..471415e 100644 --- a/src/main.c +++ b/src/main.c @@ -4,6 +4,8 @@ #include "game/level.h" #include "game/levelgen.h" #include "game/editor.h" +#include "game/stats.h" +#include "game/analytics.h" #include "config.h" #include #include @@ -46,6 +48,10 @@ static int s_station_depth = 0; static int s_mars_depth = 0; #define MARS_BASE_GEN_COUNT 2 +/* ── Analytics / stats tracking ── */ +static GameStats s_stats; +static bool s_session_active = false; + /* ── Pause menu state ── */ #define PAUSE_ITEM_COUNT 3 static int s_pause_selection = 0; /* 0=Resume, 1=Restart, 2=Quit */ @@ -238,6 +244,21 @@ static void load_mars_base_level(void) { s_level_path[0] = '\0'; } +/* ── Analytics session helpers ── */ +static void begin_session(void) { + stats_reset(&s_stats); + stats_set_active(&s_stats); + analytics_session_start(); + s_session_active = true; +} + +static void end_session(const char *reason) { + if (!s_session_active) return; + s_session_active = false; + stats_set_active(NULL); + analytics_session_end(&s_stats, reason); +} + /* ── Switch to editor mode ── */ static void enter_editor(void) { if (s_mode == MODE_PLAY) { @@ -305,15 +326,19 @@ static void restart_level(void) { * ═══════════════════════════════════════════════════ */ static void game_init(void) { + analytics_init(); + if (s_use_editor) { enter_editor(); } else if (s_use_procgen) { load_generated_level(); + begin_session(); } else { if (!load_level_file("assets/levels/moon01.lvl")) { fprintf(stderr, "Failed to load level!\n"); g_engine.running = false; } + begin_session(); } } @@ -347,12 +372,15 @@ static void pause_update(void) { break; case 1: /* Restart */ s_mode = MODE_PLAY; + end_session("quit"); restart_level(); + begin_session(); break; case 2: /* Quit */ if (s_testing_from_editor) { return_to_editor(); } else { + end_session("quit"); g_engine.running = false; } break; @@ -364,6 +392,7 @@ static void game_update(float dt) { /* Handle deferred level load from JS shell dropdown. */ if (s_js_load_request && s_js_load_path[0]) { s_js_load_request = 0; + end_session("quit"); /* Tear down whatever mode we are in. */ if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED) { @@ -389,6 +418,7 @@ static void game_update(float dt) { s_js_load_path[0] = '\0'; SDL_SetWindowTitle(g_engine.window, "Jump 'n Run"); + begin_session(); return; } #endif @@ -437,22 +467,35 @@ static void game_update(float dt) { bool r_pressed = input_key_held(SDL_SCANCODE_R); if (r_pressed && !r_was_pressed) { printf("\n=== Regenerating level ===\n"); + end_session("quit"); level_free(&s_level); s_gen_seed = (uint32_t)time(NULL); s_use_procgen = true; load_generated_level(); + begin_session(); } r_was_pressed = r_pressed; level_update(&s_level, dt); + /* Accumulate play time */ + if (s_session_active) { + s_stats.time_elapsed += dt; + } + /* Check for level exit transition */ if (level_exit_triggered(&s_level)) { const char *target = s_level.exit_target; + /* Record the level completion in stats */ + if (s_session_active) { + s_stats.levels_completed++; + } + if (target[0] == '\0') { /* Empty target = victory / end of game */ printf("Level complete! (no next level)\n"); + end_session("completed"); /* Loop back to the beginning, reset progression state */ level_free(&s_level); s_station_depth = 0; @@ -460,6 +503,7 @@ static void game_update(float dt) { if (!load_level_file("assets/levels/moon01.lvl")) { g_engine.running = false; } + begin_session(); } else if (strcmp(target, "generate") == 0) { /* Procedurally generated next level */ printf("Transitioning to generated level\n"); @@ -549,6 +593,8 @@ static void game_render(float interpolation) { } static void game_shutdown(void) { + end_session("quit"); + /* Always free both — editor may have been initialized even if we're * currently in play mode (e.g. shutdown during test play). editor_free * and level_free are safe to call on zeroed/already-freed structs. */ diff --git a/web/shell.html b/web/shell.html index 6fa3b9e..ddfd862 100644 --- a/web/shell.html +++ b/web/shell.html @@ -107,7 +107,9 @@ -
+
@@ -282,6 +284,27 @@ document.title = 'Jump \'n Run - Level Editor'; } } + + /* ── Analytics: end session on tab close ────────────── */ + window.addEventListener('beforeunload', function() { + if (typeof Module !== 'undefined' && Module._analyticsSessionId && + Module._analyticsUrl) { + /* Use sendBeacon for reliability during page unload */ + var sid = Module._analyticsSessionId; + var body = JSON.stringify({ + score: 0, + level_reached: 1, + lives_used: 0, + duration_seconds: 0, + end_reason: 'quit' + }); + var blob = new Blob([body], { type: 'application/json' }); + navigator.sendBeacon( + Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', + blob + ); + } + }); {{{ SCRIPT }}} From 322dd184abad4e3854ef17b5b8a7b610e942fb70 Mon Sep 17 00:00:00 2001 From: LeSerjant Date: Sun, 8 Mar 2026 19:29:11 +0000 Subject: [PATCH 2/2] Fix review issues in analytics integration 1. Race condition: session_end now waits for an in-flight session_start promise before sending, so quick restarts don't drop the end call. 2. beforeunload: replaced sendBeacon (which can't set headers) with fetch(..., keepalive: true) so the X-API-Key header is included and the backend doesn't 401. 3. Stats double-counting: removed stats_record_damage_dealt and stats_record_kill from damage_entity (which was called for all damage including player deaths). Now only recorded at player-sourced call sites (projectile hits, stomps). 4. Removed const-cast: analytics_session_end now takes GameStats* (non-const) since stats_update_score mutates it. 5. beforeunload now uses stashed stats from the last C-side session_end call instead of hardcoded zeroes. Session ID is cleared synchronously before async fetch to prevent races. 6. Removed unused stdint.h include from stats.h. --- src/game/analytics.c | 108 +++++++++++++++++++++++++++---------------- src/game/analytics.h | 3 +- src/game/level.c | 8 +++- src/game/stats.h | 2 - web/shell.html | 24 +++++++--- 5 files changed, 94 insertions(+), 51 deletions(-) diff --git a/src/game/analytics.c b/src/game/analytics.c index 9ab6cd7..9c0333b 100644 --- a/src/game/analytics.c +++ b/src/game/analytics.c @@ -18,6 +18,8 @@ EM_JS(void, js_analytics_init, (), { * -D defines, falling back to sensible defaults. */ Module._analyticsClientId = localStorage.getItem('jnr_client_id'); Module._analyticsSessionId = null; + Module._analyticsStartPending = null; /* Promise while start is in-flight */ + Module._analyticsLastStats = null; /* stashed for beforeunload fallback */ /* Runtime config: check for data attributes on the canvas container * first, then fall back to compiled-in defaults. */ @@ -36,7 +38,8 @@ EM_JS(void, js_analytics_init, (), { } }); -/* Start a new session. Sends POST /api/analytics/session/start/. */ +/* Start a new session. Sends POST /api/analytics/session/start/. + * Stores the in-flight promise so session_end can wait for it. */ EM_JS(void, js_analytics_session_start, (), { if (!Module._analyticsUrl) return; @@ -64,7 +67,7 @@ EM_JS(void, js_analytics_session_start, (), { } }); - fetch(Module._analyticsUrl + '/api/analytics/session/start/', { + Module._analyticsStartPending = fetch(Module._analyticsUrl + '/api/analytics/session/start/', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -75,50 +78,77 @@ EM_JS(void, js_analytics_session_start, (), { .then(function(r) { return r.json(); }) .then(function(data) { Module._analyticsSessionId = data.session_id || null; + Module._analyticsStartPending = null; console.log('[analytics] Session started: ' + Module._analyticsSessionId); }) .catch(function(err) { + Module._analyticsStartPending = null; console.error('[analytics] Session start failed:', err); }); }); -/* End the current session with gameplay stats. - * Parameters are passed from C as integers/string. */ -EM_JS(void, js_analytics_session_end, (int score, int level_reached, - int lives_used, int duration_secs, - const char *end_reason_ptr), { - if (!Module._analyticsUrl || !Module._analyticsSessionId) return; +/* Internal helper: send the session-end POST (used by both the C wrapper + * and the beforeunload fallback). */ +EM_JS(void, js_analytics_send_end, (int score, int level_reached, + int lives_used, int duration_secs, + const char *end_reason_ptr), { + /* Helper that performs the actual end request given a session id. */ + function doEnd(sid, endReason, score, levelReached, livesUsed, durationSecs) { + var body = JSON.stringify({ + score: score, + level_reached: levelReached > 0 ? levelReached : 1, + lives_used: livesUsed, + duration_seconds: durationSecs, + end_reason: endReason + }); + + /* Stash stats for the beforeunload fallback */ + Module._analyticsLastStats = body; + + return fetch(Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': Module._analyticsKey + }, + body: body, + keepalive: true + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + console.log('[analytics] Session ended: ' + sid + + (data.new_high_score ? ' (NEW HIGH SCORE!)' : '')); + }) + .catch(function(err) { + console.error('[analytics] Session end failed:', err); + }); + } + + if (!Module._analyticsUrl) return; var endReason = UTF8ToString(end_reason_ptr); + + /* If session start is still in-flight, wait for it before ending. */ + if (Module._analyticsStartPending) { + var pending = Module._analyticsStartPending; + /* Clear session synchronously so duplicate end calls are harmless */ + Module._analyticsStartPending = null; + pending.then(function() { + var sid = Module._analyticsSessionId; + if (sid) { + Module._analyticsSessionId = null; + doEnd(sid, endReason, score, level_reached, lives_used, duration_secs); + } + }); + return; + } + + if (!Module._analyticsSessionId) return; + var sid = Module._analyticsSessionId; - - var body = JSON.stringify({ - score: score, - level_reached: level_reached > 0 ? level_reached : 1, - lives_used: lives_used, - duration_seconds: duration_secs, - end_reason: endReason - }); - - fetch(Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-API-Key': Module._analyticsKey - }, - body: body - }) - .then(function(r) { return r.json(); }) - .then(function(data) { - console.log('[analytics] Session ended: ' + sid + - (data.new_high_score ? ' (NEW HIGH SCORE!)' : '')); - }) - .catch(function(err) { - console.error('[analytics] Session end failed:', err); - }); - - /* Clear session so duplicate end calls are harmless */ + /* Clear synchronously before the async request to prevent races */ Module._analyticsSessionId = null; + doEnd(sid, endReason, score, level_reached, lives_used, duration_secs); }); /* ── C wrappers ─────────────────────────────────────────────────── */ @@ -131,9 +161,9 @@ void analytics_session_start(void) { js_analytics_session_start(); } -void analytics_session_end(const GameStats *stats, const char *end_reason) { - stats_update_score((GameStats *)stats); - js_analytics_session_end( +void analytics_session_end(GameStats *stats, const char *end_reason) { + stats_update_score(stats); + js_analytics_send_end( stats->score, stats->levels_completed > 0 ? stats->levels_completed : 1, stats->deaths, @@ -151,7 +181,7 @@ void analytics_init(void) { void analytics_session_start(void) {} -void analytics_session_end(const GameStats *stats, const char *end_reason) { +void analytics_session_end(GameStats *stats, const char *end_reason) { (void)stats; (void)end_reason; } diff --git a/src/game/analytics.h b/src/game/analytics.h index 1d514f8..cc55822 100644 --- a/src/game/analytics.h +++ b/src/game/analytics.h @@ -11,7 +11,8 @@ void analytics_init(void); void analytics_session_start(void); /* End the current analytics session with final stats. + * Computes the composite score before sending. * end_reason: "death", "quit", "timeout", or "completed". */ -void analytics_session_end(const GameStats *stats, const char *end_reason); +void analytics_session_end(GameStats *stats, const char *end_reason); #endif /* JNR_ANALYTICS_H */ diff --git a/src/game/level.c b/src/game/level.c index 7a12d5c..cb5ba85 100644 --- a/src/game/level.c +++ b/src/game/level.c @@ -175,11 +175,9 @@ bool level_load_generated(Level *level, Tilemap *gen_map) { static Camera *s_active_camera = NULL; static void damage_entity(Entity *target, int damage) { - stats_record_damage_dealt(damage); target->health -= damage; if (target->health <= 0) { target->flags |= ENTITY_DEAD; - stats_record_kill(); /* Death particles — centered on entity */ Vec2 center = vec2( @@ -215,6 +213,8 @@ static void damage_player(Entity *player, int damage, Entity *source) { PlayerData *ppd = (PlayerData *)player->data; stats_record_damage_taken(damage); damage_entity(player, damage); + /* Note: damage_taken is recorded here; damage_dealt and kills are + * recorded at the specific call sites that deal player-sourced damage. */ /* Screen shake on player hit (stronger) */ if (s_active_camera) { @@ -267,8 +267,10 @@ static void handle_collisions(EntityManager *em) { /* Player bullet hits enemies */ if (from_player && entity_is_enemy(b)) { if (physics_overlap(&a->body, &b->body)) { + stats_record_damage_dealt(a->damage); damage_entity(b, a->damage); stats_record_shot_hit(); + if (b->flags & ENTITY_DEAD) stats_record_kill(); hit = true; } } @@ -300,7 +302,9 @@ static void handle_collisions(EntityManager *em) { a->body.pos.y + a->body.size.y * 0.5f); if (stomping) { + stats_record_damage_dealt(2); damage_entity(a, 2); + if (a->flags & ENTITY_DEAD) stats_record_kill(); player->body.vel.y = -PLAYER_JUMP_FORCE * 0.7f; } else { damage_player(player, a->damage, a); diff --git a/src/game/stats.h b/src/game/stats.h index b1d8cbd..d00f8d6 100644 --- a/src/game/stats.h +++ b/src/game/stats.h @@ -1,8 +1,6 @@ #ifndef JNR_STATS_H #define JNR_STATS_H -#include - /* Per-session gameplay statistics, accumulated during play and * submitted to the analytics backend when the session ends. */ typedef struct GameStats { diff --git a/web/shell.html b/web/shell.html index ddfd862..fea2750 100644 --- a/web/shell.html +++ b/web/shell.html @@ -286,23 +286,33 @@ } /* ── Analytics: end session on tab close ────────────── */ + /* Fallback for when the WASM shutdown path didn't get a chance to + * run (e.g. user closes tab mid-game). Uses fetch with keepalive + * so the browser can send the request after the page is gone, and + * includes the X-API-Key header that sendBeacon can't carry. */ window.addEventListener('beforeunload', function() { if (typeof Module !== 'undefined' && Module._analyticsSessionId && Module._analyticsUrl) { - /* Use sendBeacon for reliability during page unload */ var sid = Module._analyticsSessionId; - var body = JSON.stringify({ + Module._analyticsSessionId = null; + /* Use stashed stats from the last C-side update if available, + * otherwise send minimal data so the session isn't left open. */ + var body = Module._analyticsLastStats || JSON.stringify({ score: 0, level_reached: 1, lives_used: 0, duration_seconds: 0, end_reason: 'quit' }); - var blob = new Blob([body], { type: 'application/json' }); - navigator.sendBeacon( - Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', - blob - ); + fetch(Module._analyticsUrl + '/api/analytics/session/' + sid + '/end/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': Module._analyticsKey + }, + body: body, + keepalive: true + }); } });