1 Commits

Author SHA1 Message Date
5517834dd4 Fix #28: stash stats periodically so beforeunload sends real scores
All checks were successful
CI / build (pull_request) Successful in 32s
Module._analyticsLastStats was only set when the C shutdown path ran
(analytics_session_end). When a player closes the browser tab mid-game,
the beforeunload fallback found _analyticsLastStats null and sent
score 0 to the backend.

Add analytics_stash_stats() which updates _analyticsLastStats every
~1 second during gameplay. The beforeunload handler now always has
current stats to send.
2026-03-19 13:24:21 +00:00
6 changed files with 52 additions and 263 deletions

View File

@@ -103,39 +103,20 @@ EM_JS(void, js_analytics_session_start, (), {
});
});
/* Stash current stats into Module._analyticsLastStats so the
* beforeunload fallback has real data if the user closes the tab.
* Called periodically from C (e.g. once per second). */
EM_JS(void, js_analytics_stash_stats, (int score, int level_reached,
int lives_used, int duration_secs), {
if (!Module._analyticsUrl) return;
Module._analyticsLastStats = JSON.stringify({
score: score,
level_reached: level_reached > 0 ? level_reached : 1,
lives_used: lives_used,
duration_seconds: duration_secs,
end_reason: 'quit'
});
});
/* 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,
const char *player_name_ptr), {
const char *end_reason_ptr), {
/* Helper that performs the actual end request given a session id. */
function doEnd(sid, endReason, playerName, score, levelReached,
livesUsed, durationSecs) {
var payload = {
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
};
if (playerName) payload.player_name = playerName;
var body = JSON.stringify(payload);
});
/* Stash stats for the beforeunload fallback */
Module._analyticsLastStats = body;
@@ -162,7 +143,6 @@ EM_JS(void, js_analytics_send_end, (int score, int level_reached,
if (!Module._analyticsUrl) return;
var endReason = UTF8ToString(end_reason_ptr);
var playerName = player_name_ptr ? UTF8ToString(player_name_ptr) : '';
/* If session start is still in-flight, wait for it before ending. */
if (Module._analyticsStartPending) {
@@ -173,8 +153,7 @@ EM_JS(void, js_analytics_send_end, (int score, int level_reached,
var sid = Module._analyticsSessionId;
if (sid) {
Module._analyticsSessionId = null;
doEnd(sid, endReason, playerName, score, level_reached,
lives_used, duration_secs);
doEnd(sid, endReason, score, level_reached, lives_used, duration_secs);
}
});
return;
@@ -185,8 +164,21 @@ EM_JS(void, js_analytics_send_end, (int score, int level_reached,
var sid = Module._analyticsSessionId;
/* Clear synchronously before the async request to prevent races */
Module._analyticsSessionId = null;
doEnd(sid, endReason, playerName, score, level_reached,
lives_used, duration_secs);
doEnd(sid, endReason, score, level_reached, lives_used, duration_secs);
});
/* Stash current stats into Module._analyticsLastStats so the
* beforeunload fallback sends real data if the tab is closed. */
EM_JS(void, js_analytics_stash_stats, (int score, int level_reached,
int lives_used, int duration_secs), {
if (!Module._analyticsUrl || !Module._analyticsSessionId) return;
Module._analyticsLastStats = JSON.stringify({
score: score,
level_reached: level_reached > 0 ? level_reached : 1,
lives_used: lives_used,
duration_seconds: duration_secs,
end_reason: 'quit'
});
});
/* ── C wrappers ─────────────────────────────────────────────────── */
@@ -199,16 +191,14 @@ void analytics_session_start(void) {
js_analytics_session_start();
}
void analytics_session_end(GameStats *stats, const char *end_reason,
const char *player_name) {
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,
(int)stats->time_elapsed,
end_reason,
player_name
end_reason
);
}
@@ -231,11 +221,9 @@ void analytics_init(void) {
void analytics_session_start(void) {}
void analytics_session_end(GameStats *stats, const char *end_reason,
const char *player_name) {
void analytics_session_end(GameStats *stats, const char *end_reason) {
(void)stats;
(void)end_reason;
(void)player_name;
}
void analytics_stash_stats(GameStats *stats) {

View File

@@ -12,14 +12,12 @@ 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".
* player_name may be NULL or empty if no name was entered. */
void analytics_session_end(GameStats *stats, const char *end_reason,
const char *player_name);
* end_reason: "death", "quit", "timeout", or "completed". */
void analytics_session_end(GameStats *stats, const char *end_reason);
/* Stash current stats into JS so the beforeunload fallback has
* real data if the user closes the tab mid-game. Call periodically
* (e.g. once per second or on level completion). No-op on native. */
/* Stash current stats for the beforeunload fallback so that
* closing the browser tab sends real data instead of zeros.
* Call periodically from the game loop. No-op on non-WASM. */
void analytics_stash_stats(GameStats *stats);
#endif /* JNR_ANALYTICS_H */

View File

@@ -591,14 +591,18 @@ void level_update(Level *level, float dt) {
/* Fallback direct exit zone check (for levels without ship exit) */
check_exit_zones(level);
/* Check for player death (skip if player boarded exit ship).
* Don't auto-respawn — set a flag for main.c to handle lives. */
/* Check for player respawn (skip if player boarded exit ship) */
if (!level->exit_ship_boarded) {
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();
level->player_death_pending = true;
player_respawn(e, level->map.player_spawn);
Vec2 center = vec2(
e->body.pos.x + e->body.size.x * 0.5f,
e->body.pos.y + e->body.size.y * 0.5f
);
camera_follow(&level->camera, center, vec2_zero(), dt);
break;
}
}
@@ -755,18 +759,6 @@ void level_render(Level *level, float interpolation) {
renderer_draw_rect(pos, size, heart_color, LAYER_HUD, cam);
}
/* Draw lives counter next to hearts */
{
GameStats *st = stats_get_active();
if (st && st->lives > 0) {
char lives_buf[8];
snprintf(lives_buf, sizeof(lives_buf), "x%d", st->lives);
int lx = 8 + player->max_health * 14 + 4;
font_draw_text(g_engine.renderer, lives_buf, lx, 8,
(SDL_Color){200, 200, 200, 255});
}
}
/* Draw jetpack charge indicators */
int charges, max_charges;
float recharge_pct;
@@ -825,17 +817,6 @@ void level_render(Level *level, float interpolation) {
renderer_flush(cam);
}
void level_respawn_player(Level *level) {
level->player_death_pending = false;
for (int i = 0; i < level->entities.count; i++) {
Entity *e = &level->entities.entities[i];
if (e->active && e->type == ENT_PLAYER) {
player_respawn(e, level->map.player_spawn);
break;
}
}
}
void level_free(Level *level) {
audio_stop_music();

View File

@@ -27,9 +27,6 @@ typedef struct Level {
bool exit_ship_spawned; /* exit spacecraft has been spawned */
bool exit_ship_boarded; /* player has entered the ship */
int exit_zone_idx; /* which exit zone the ship is for */
/* ── Death / game over ────────────────── */
bool player_death_pending; /* player died, awaiting main.c */
} Level;
bool level_load(Level *level, const char *path);
@@ -42,8 +39,4 @@ void level_free(Level *level);
* The target path is stored in level->exit_target. */
bool level_exit_triggered(const Level *level);
/* Respawn the player at the level's spawn point. Called by main.c
* after acknowledging a death (decrementing lives). */
void level_respawn_player(Level *level);
#endif /* JNR_LEVEL_H */

View File

@@ -16,7 +16,6 @@ typedef struct GameStats {
int damage_taken; /* total HP lost */
int damage_dealt; /* total HP dealt to enemies */
float time_elapsed; /* wall-clock seconds */
int lives; /* remaining lives (set by main) */
} GameStats;
/* Reset all stats to zero (call at session start). */

View File

@@ -27,7 +27,6 @@ typedef enum GameMode {
MODE_EDITOR,
MODE_PAUSED,
MODE_TRANSITION,
MODE_GAMEOVER,
} GameMode;
static Level s_level;
@@ -56,23 +55,13 @@ static int s_mars_depth = 0;
/* ── Analytics / stats tracking ── */
static GameStats s_stats;
static bool s_session_active = false;
/* ── Lives system ── */
#define STARTING_LIVES 3
static int s_lives = STARTING_LIVES;
static float s_stash_timer = 0.0f; /* timer for periodic stats stash */
#define STASH_INTERVAL 2.0f /* seconds between stats stashes */
static int s_stash_tick = 0; /* frames since last analytics stash */
#define ANALYTICS_STASH_INTERVAL 60 /* stash stats every ~1 second */
/* ── Pause menu state ── */
#define PAUSE_ITEM_COUNT 3
static int s_pause_selection = 0; /* 0=Resume, 1=Restart, 2=Quit */
/* ── Game over state ── */
#define NAME_MAX_LEN 3
static char s_go_name[NAME_MAX_LEN + 1] = "AAA"; /* player name for scoreboard */
static int s_go_cursor = 0; /* which character is selected */
static bool s_go_submitted = false; /* name has been submitted */
/* ── Level transition state ── */
static TransitionState s_transition;
static char s_pending_target[ASSET_PATH_MAX] = {0}; /* exit target stashed during transition */
@@ -272,19 +261,17 @@ static void load_mars_base_level(void) {
/* ── Analytics session helpers ── */
static void begin_session(void) {
stats_reset(&s_stats);
s_lives = STARTING_LIVES;
s_stats.lives = s_lives;
s_stash_timer = 0.0f;
stats_set_active(&s_stats);
analytics_session_start();
s_session_active = true;
s_stash_tick = 0;
}
static void end_session(const char *reason, const char *player_name) {
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, player_name);
analytics_session_end(&s_stats, reason);
}
/* ── Switch to editor mode ── */
@@ -358,7 +345,7 @@ static void dispatch_level_load(const char *target) {
if (target[0] == '\0') {
/* Empty target = victory / end of game. */
printf("Level complete! (no next level)\n");
end_session("completed", NULL);
end_session("completed");
level_free(&s_level);
s_station_depth = 0;
s_mars_depth = 0;
@@ -446,7 +433,7 @@ static void pause_update(void) {
break;
case 1: /* Restart */
s_mode = MODE_PLAY;
end_session("quit", NULL);
end_session("quit");
restart_level();
begin_session();
break;
@@ -454,7 +441,7 @@ static void pause_update(void) {
if (s_testing_from_editor) {
return_to_editor();
} else {
end_session("quit", NULL);
end_session("quit");
g_engine.running = false;
}
break;
@@ -478,11 +465,11 @@ static void game_update(float dt) {
return;
}
end_session("quit", NULL);
end_session("quit");
/* Tear down whatever mode we are in. */
if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED
|| s_mode == MODE_TRANSITION || s_mode == MODE_GAMEOVER) {
|| s_mode == MODE_TRANSITION) {
debuglog_set_level(NULL, NULL);
transition_reset(&s_transition);
level_free(&s_level);
@@ -527,11 +514,6 @@ static void game_update(float dt) {
return;
}
if (s_mode == MODE_GAMEOVER) {
gameover_update();
return;
}
if (s_mode == MODE_TRANSITION) {
transition_update(&s_transition, dt, &s_level.camera);
@@ -587,7 +569,7 @@ 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", NULL);
end_session("quit");
debuglog_set_level(NULL, NULL);
level_free(&s_level);
s_gen_seed = (uint32_t)time(NULL);
@@ -602,25 +584,12 @@ static void game_update(float dt) {
/* Accumulate play time */
if (s_session_active) {
s_stats.time_elapsed += dt;
}
/* Check for player death — decrement lives or trigger game over */
if (s_level.player_death_pending) {
s_level.player_death_pending = false;
s_lives--;
s_stats.lives = s_lives;
if (s_lives <= 0) {
enter_gameover();
return;
/* Periodically stash stats for the beforeunload fallback. */
if (++s_stash_tick >= ANALYTICS_STASH_INTERVAL) {
s_stash_tick = 0;
analytics_stash_stats(&s_stats);
}
level_respawn_player(&s_level);
}
/* Periodically stash stats so beforeunload has real data */
s_stash_timer += dt;
if (s_stash_timer >= STASH_INTERVAL && s_session_active) {
analytics_stash_stats(&s_stats);
s_stash_timer = 0.0f;
}
/* Check for level exit transition */
@@ -687,140 +656,6 @@ static void pause_render(void) {
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
}
/* ── Enter game over mode ── */
static void enter_gameover(void) {
s_mode = MODE_GAMEOVER;
s_go_cursor = 0;
s_go_submitted = false;
memcpy(s_go_name, "AAA", NAME_MAX_LEN + 1);
/* Finalize score for display */
stats_update_score(&s_stats);
}
/* ── Game over: handle name entry input ── */
static void gameover_update(void) {
if (s_go_submitted) {
/* After submission, wait for confirm to restart */
bool confirm = input_pressed(ACTION_JUMP)
|| input_key_pressed(SDL_SCANCODE_RETURN)
|| input_key_pressed(SDL_SCANCODE_RETURN2);
if (confirm) {
end_session("death", s_go_name);
debuglog_set_level(NULL, NULL);
level_free(&s_level);
s_station_depth = 0;
s_mars_depth = 0;
s_mode = MODE_PLAY;
if (!load_level_file("assets/levels/moon01.lvl")) {
g_engine.running = false;
}
begin_session();
}
return;
}
/* Cycle letter up/down */
if (input_pressed(ACTION_UP)) {
s_go_name[s_go_cursor]++;
if (s_go_name[s_go_cursor] > 'Z') s_go_name[s_go_cursor] = 'A';
}
if (input_pressed(ACTION_DOWN)) {
s_go_name[s_go_cursor]--;
if (s_go_name[s_go_cursor] < 'A') s_go_name[s_go_cursor] = 'Z';
}
/* Move cursor left/right */
if (input_pressed(ACTION_LEFT)) {
if (s_go_cursor > 0) s_go_cursor--;
}
if (input_pressed(ACTION_RIGHT)) {
if (s_go_cursor < NAME_MAX_LEN - 1) s_go_cursor++;
}
/* Confirm name */
bool confirm = input_pressed(ACTION_JUMP)
|| input_key_pressed(SDL_SCANCODE_RETURN)
|| input_key_pressed(SDL_SCANCODE_RETURN2);
if (confirm) {
s_go_submitted = true;
}
}
/* ── Game over: render overlay ── */
static void gameover_render(void) {
SDL_Renderer *r = g_engine.renderer;
/* Dark overlay */
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(r, 0, 0, 0, 180);
SDL_Rect overlay = {0, 0, SCREEN_WIDTH, SCREEN_HEIGHT};
SDL_RenderFillRect(r, &overlay);
SDL_Color col_title = {220, 50, 50, 255};
SDL_Color col_text = {200, 200, 200, 255};
SDL_Color col_score = {255, 220, 80, 255};
SDL_Color col_dim = {120, 120, 130, 255};
int cy = SCREEN_HEIGHT / 2 - 50;
font_draw_text_centered(r, "GAME OVER", cy, SCREEN_WIDTH, col_title);
cy += 20;
/* Show final score */
{
char score_buf[32];
snprintf(score_buf, sizeof(score_buf), "SCORE: %d", s_stats.score);
font_draw_text_centered(r, score_buf, cy, SCREEN_WIDTH, col_score);
cy += 20;
}
if (!s_go_submitted) {
font_draw_text_centered(r, "ENTER YOUR NAME", cy, SCREEN_WIDTH, col_text);
cy += 16;
/* Draw name characters with cursor highlight */
{
char name_display[16];
snprintf(name_display, sizeof(name_display), "%c %c %c",
s_go_name[0], s_go_name[1], s_go_name[2]);
int nw = font_text_width(name_display);
int nx = (SCREEN_WIDTH - nw) / 2;
int spacing = nw / 3;
for (int i = 0; i < NAME_MAX_LEN; i++) {
char ch[2] = { s_go_name[i], '\0' };
SDL_Color c = (i == s_go_cursor) ? col_score : col_text;
int cx = nx + i * spacing;
font_draw_text(r, ch, cx, cy, c);
/* Draw cursor arrow above selected character */
if (i == s_go_cursor) {
font_draw_text(r, "^", cx, cy + 10, col_score);
}
}
cy += 24;
}
font_draw_text_centered(r, "UP/DOWN=LETTER LEFT/RIGHT=MOVE",
cy, SCREEN_WIDTH, col_dim);
cy += 12;
font_draw_text_centered(r, "JUMP=CONFIRM", cy, SCREEN_WIDTH, col_dim);
} else {
/* Name submitted — show confirmation */
{
char msg[32];
snprintf(msg, sizeof(msg), "NAME: %s", s_go_name);
font_draw_text_centered(r, msg, cy, SCREEN_WIDTH, col_text);
cy += 20;
}
font_draw_text_centered(r, "PRESS JUMP TO CONTINUE",
cy, SCREEN_WIDTH, col_dim);
}
SDL_SetRenderDrawBlendMode(r, SDL_BLENDMODE_NONE);
}
static void game_render(float interpolation) {
if (s_mode == MODE_EDITOR) {
editor_render(&s_editor, interpolation);
@@ -832,25 +667,20 @@ static void game_render(float interpolation) {
/* Render the level (frozen) with the transition overlay on top. */
level_render(&s_level, interpolation);
transition_render(&s_transition);
} else if (s_mode == MODE_GAMEOVER) {
/* Render frozen game frame with game over overlay. */
level_render(&s_level, interpolation);
gameover_render();
} else {
level_render(&s_level, interpolation);
}
}
static void game_shutdown(void) {
end_session("quit", NULL);
end_session("quit");
debuglog_set_level(NULL, NULL);
/* 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. */
if (s_mode == MODE_PLAY || s_mode == MODE_PAUSED
|| s_mode == MODE_TRANSITION || s_mode == MODE_GAMEOVER
|| s_testing_from_editor) {
|| s_mode == MODE_TRANSITION || s_testing_from_editor) {
level_free(&s_level);
}
if (s_mode == MODE_EDITOR || s_use_editor) {