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 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-08 17:11:39 +00:00
parent 4407932a2d
commit a23ecaf4c1
8 changed files with 331 additions and 1 deletions

159
src/game/analytics.c Normal file
View File

@@ -0,0 +1,159 @@
#include "game/analytics.h"
#include <stdio.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
/* ── 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__ */

17
src/game/analytics.h Normal file
View File

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

View File

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

View File

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

29
src/game/stats.c Normal file
View File

@@ -0,0 +1,29 @@
#include "game/stats.h"
#include <string.h>
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; }

45
src/game/stats.h Normal file
View File

@@ -0,0 +1,45 @@
#ifndef JNR_STATS_H
#define JNR_STATS_H
#include <stdint.h>
/* 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 */