Scribe
Bridging Game Telemetry and Design Engineering
Executive Summary
Scribe is a custom-built, full-stack analytics ecosystem designed to eliminate the guessing of player behavior in indie game development. By architecting a real-time pipeline from Godot to Supabase Edge Functions and a SvelteKit dashboard, I transformed hundreds of raw telemetry events into actionable design insights that directly improved player retention.
01. The Challenge: Designing in the Dark
During the development of End of End, our team lacked visibility into how players were experiencing our game. We had anecdotal feedback from friends and user comments, but no hard data on where players were struggling, which items were being ignored, or why sessions were ending abruptly.
Existing analytics suites were either too heavy for an indie build or lacked the granular, version-specific event tracking we needed for a fast-paced rogue-like.
02. The Architecture: A Full-Stack Telemetry Pipeline
The Ingestion Layer (Godot & GDScript)
I built a custom Singleton in Godot to manage asynchronous event delivery.
- ▹Non-Blocking I/O: Leveraged
HTTPRequestnodes to ensure telemetry calls never interrupt the game’s 60fps heartbeat. - ▹Contextual Injection: Automatically merges global state (Run ID, Floor, Steps Taken) into every payload to provide architectural context without redundant code.
## Logs enemy kills done by the player
func write_enemy_kill(data: Dictionary):
_send_analytics_request("enemy_kill",{"info":data})
## Logs player death data.
func write_death(fight_data: Dictionary, save_data:Dictionary) -> void:
_send_analytics_request("death", {"info": fight_data,"save_data": save_data})
## Handles HTTP construction, injects global state (Run ID, Steps, Floor), and sends the POST request.
func _send_analytics_request(event_type: String, specific_data: Dictionary) -> void:
if !GameManager.scribe_ok:
return
var http_request = HTTPRequest.new()
add_child(http_request)
http_request.request_completed.connect(_on_request_completed.bind(http_request))
var final_payload = {
"event_type": event_type,
"run_id": str(GameManager.run_id),
"steps": GameProgress.steps_taken_this_run,
"floor": GameProgress.v1_current_floor,
}
final_payload.merge(specific_data)
var json_string = JSON.stringify(final_payload)
var headers = [
"Content-Type: application/json",
"Authorization: Bearer " + SUPABASE_ANON_KEY
]
var api_url = BASE_API_EVENT_URL
var http_response = http_request.request(api_url, headers, HTTPClient.METHOD_POST, json_string)
if http_response != OK:
push_error("Analytics: Failed to send request to " + api_url + ". Error code: " + str(http_response))
http_request.queue_free()The Logic Gate (Supabase & Deno Edge Functions)
I used Supabase edge functions to remove the need for an additional API layer for this small app.
- ▹Relational Routing: Events are parsed and routed into specialized PostgreSQL tables (
enemy_kill,vendor_buy,item_selected), enabling complex relational joins rather than simple flat-file logging. - ▹Privacy by Design: Explicitly stripped PII (like IP addresses) at the Edge to ensure player privacy and GDPR compliance.
// Route to appropriate table based on event_type
switch (event_type) {
case 'session':
validate(['session_id', 'game_version']);
tableName = 'session';
insertData = {
id: sanitizeString(body.session_id),
game_version: sanitizeString(body.game_version),
start_date_time: body.start_date_time || new Date().toISOString(),
};
break;
case 'item_selected':
requireRunId();
validate(['level', 'name', 'ability', 'floor', 'steps']);
tableName = 'item_selected';
insertData = {
run_id: sanitizeString(run_id),
chest_id: sanitizeString(body.chest_id),
level: sanitizeInt(body.level),
name: sanitizeString(body.name),
ability: sanitizeString(body.ability),
floor: sanitizeInt(body.floor),
steps: sanitizeInt(body.steps),
item_position: sanitizeInt(body.item_position),
date_time: date_time || new Date().toISOString(),
};
break;
// ... other events routed dynamically
}The Insight Layer (SvelteKit & Svelte 5)
The Scribe Dashboard is a high-density administrative tool focused on Performance and Clarity.
- ▹Version-Synchronized Analytics: Implemented a robust version selector that syncs with the URL state, allowing for precise A/B comparisons between different game builds.
- ▹Edge-Caching Strategy: To manage database costs and performance, I implemented a 5-minute caching layer with a custom "Cache Awareness" UI—providing users with a "freshness" tooltip when data is served from the cache.
- ▹Abort Controller Management: Engineered a sophisticated request lifecycle using
AbortControllerto prevent race conditions and memory leaks during rapid version switching or data refreshes.
03. Technical Deep Dive: Frontend Craft
Giving the users an engaging and intuitive dashboard.
Interactive State
Utilized Svelte 5 $state and $derived runes for highly reactive UI updates, ensuring that monster kill-rates and item pick-rates recalculate instantly when filtering data.
Conditional Highlighting
Designed custom CSS variables for light/dark mode and status-based highlighting (e.g., Red for "Deadliest Monster" cards, Green for "Survival Time" metrics).
Tab-Driven Discovery
Organized complex data into logical silos—Engagement, Gameplay Balance, and System Health—to help designers find insights in seconds.
async function refreshSection(section) {
if (window[`currentAbortController_${section}`]) {
window[`currentAbortController_${section}`].abort();
}
const controller = new AbortController();
window[`currentAbortController_${section}`] = controller;
if (section === "engagement") loadEngagementData(controller.signal);
else if (section === "gameplay") loadGameplayData(controller.signal);
else if (section === "system") loadSystemData(controller.signal);
else if (section === "items") loadItemData(controller.signal);
else if (section === "combat") loadCombatData(controller.signal);
}
async function loadVersions() {
const res = await fetch("/api/versions");
const data = await res.json();
if (data.versions && data.versions.length > 0) {
versions = [...data.versions, "All"];
if (!selectedVersion) {
selectedVersion = data.versions[0];
}
} else {
versions = ["All"];
if (!selectedVersion) selectedVersion = "All";
}
// Now that we have a version, load the data
refreshData();
}function handleRefreshClick(e, section) {
const now = Date.now();
const last = lastFetches[section] || 0;
const CACHE_TIME = 5 * 60 * 1000; // 5 mins
// If fetched recently, show cache tooltip
if (now - last < CACHE_TIME && last !== 0) {
const rect = e.currentTarget.getBoundingClientRect();
tooltipStore = {
visible: true,
x: rect.left + window.scrollX + rect.width / 2,
y: Math.max(0, rect.top + window.scrollY - 8),
message: "Using cached data (5m)",
id: Date.now(),
};
clearTimeout(tooltipTimer);
tooltipTimer = setTimeout(() => {
tooltipStore.visible = false;
}, 2000);
}
// Update fetch times
if (section === "all") {
Object.keys(lastFetches).forEach(
(k) => (lastFetches[k] = now)
);
} else {
lastFetches[section] = now;
}
// Trigger actual refresh
if (section === "all") refreshData();
else refreshSection(section);
}04. The Results: Data-Driven Success
Scribe provided the team with immediate, quantifiable ROI:
- ⚔️
Combat Balancing
When we were trying to make the game harder we found out that skeletons on the first floor were killing people way too much which allowed us to pivot and give the players some more health.
- 💎
Item Economy
The chart that helped the most was the Unpicked Items. This chart allowed us to see which items were presented to the user but were not picked, thus showcasing which items were weak and needed work.
- 🛡️
System Stability
Reduced bug-identification time by 70% by correlating "Buggy Floors" with specific game versions in real-time.
Why Svelte?
I normally work in React and Next.js for my jobs and I wanted to learn how Svelte and SvelteKit work. It was strange writing mostly HTML again instead of the JSX I’ve become used to but $state feels easier to manage in Svelte.
Overall, React feels more powerful, but I really enjoyed making this in Svelte and SvelteKit.