Skip to content

Commit 67dc24b

Browse files
committed
Add daily challenges and local engagement metrics
1 parent caf04e2 commit 67dc24b

7 files changed

Lines changed: 223 additions & 8 deletions

File tree

PRIVACY.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Privacy
2+
3+
Little Android does not send gameplay analytics to a server.
4+
5+
The game stores the following data locally in the visitor's browser:
6+
7+
- discovered and collected signal IDs;
8+
- scanned resident names;
9+
- quest completion state;
10+
- sound preference;
11+
- aggregate counters such as sessions, first scan, completions, replays, and share intent.
12+
13+
These values contain no account identifier, free-form input, location, or device fingerprint. They are used only by the local experience and can be removed by clearing site data or resetting the journey where applicable. The game continues to work when browser storage is unavailable.
14+
15+
The share action uses the browser's native share sheet or clipboard only after the visitor activates it. Shared text contains the daily challenge code and the public project URL.
16+
17+
The page currently requests display fonts from Google Fonts. That external request is separate from gameplay measurement and is tracked for reliability/privacy review in issue #17.

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Keep chan
5454

5555
Codex review follows the repository rules in [AGENTS.md](AGENTS.md).
5656

57+
## Privacy
58+
59+
Gameplay progress, preferences, and aggregate engagement counters remain in the visitor's browser. See [PRIVACY.md](PRIVACY.md) for the exact local data and sharing behavior.
60+
5761
## License
5862

5963
Source code and original project assets are available under the [MIT License](LICENSE).

index.html

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
#quest-reset{position:absolute;top:max(88px,calc(66px + env(safe-area-inset-top)));right:max(22px,env(safe-area-inset-right));z-index:20;display:none;width:34px;height:34px;border:1px solid #D4A030;border-radius:4px;background:rgba(10,10,10,.82);color:#E8C040;font:20px monospace;cursor:pointer}
3434
#quest-reset.show{display:block}
3535
#quest-reset:focus-visible{outline:3px solid #E8E0D0;outline-offset:2px}
36+
#share-button{position:absolute;top:max(128px,calc(106px + env(safe-area-inset-top)));right:max(22px,env(safe-area-inset-right));z-index:20;display:none;width:34px;height:34px;border:1px solid #D4A030;border-radius:4px;background:rgba(10,10,10,.82);color:#E8C040;font:18px monospace;cursor:pointer}
37+
#share-button.show{display:block}
38+
#share-button:focus-visible{outline:3px solid #E8E0D0;outline-offset:2px}
3639
#journal-button{position:absolute;top:max(48px,calc(26px + env(safe-area-inset-top)));right:max(22px,env(safe-area-inset-right));z-index:20;width:42px;height:30px;border:1px solid #5A8848;border-radius:4px;background:rgba(10,10,10,.82);color:#A8C088;font:10px 'Silkscreen',monospace;cursor:pointer}
3740
#journal-button:focus-visible,#journal-close:focus-visible{outline:3px solid #E8E0D0;outline-offset:2px}
3841
#sound-button{position:absolute;right:max(20px,env(safe-area-inset-right));bottom:max(20px,env(safe-area-inset-bottom));z-index:20;width:38px;height:34px;border:1px solid #5A8848;border-radius:4px;background:rgba(10,10,10,.82);color:#A8C088;font:18px monospace;cursor:pointer}
@@ -55,6 +58,7 @@
5558
#quest{top:calc(54px + env(safe-area-inset-top));right:12px;font-size:9px}
5659
#journal-button{top:calc(74px + env(safe-area-inset-top));right:12px}
5760
#quest-reset{top:calc(112px + env(safe-area-inset-top));right:12px}
61+
#share-button{top:calc(152px + env(safe-area-inset-top));right:12px}
5862
#hint{bottom:max(88px,calc(68px + env(safe-area-inset-bottom)));width:70vw;font-size:10px;text-align:center}
5963
#action-button{display:block}
6064
#sound-button{right:max(104px,calc(104px + env(safe-area-inset-right)));bottom:max(28px,calc(28px + env(safe-area-inset-bottom)))}
@@ -82,6 +86,7 @@
8286
<div id="quest" aria-live="polite">SIGNALS 0/3</div>
8387
<button id="journal-button" type="button" aria-label="Open signal log">LOG</button>
8488
<button id="quest-reset" type="button" aria-label="Restart signal quest" title="Restart signal quest"></button>
89+
<button id="share-button" type="button" aria-label="Share completion" title="Share completion"></button>
8590
<button id="action-button" type="button" aria-label="Scan or interact">SCAN</button>
8691
<button id="sound-button" type="button" aria-label="Mute sound" title="Toggle sound"></button>
8792
<p class="sr-only" id="game-instructions">Move with WASD, arrow keys, or by selecting a destination. Press Space or Enter, or use the Scan button, to scan nearby residents or interact with the character you face.</p>
@@ -95,6 +100,7 @@ <h2 id="journal-title">SIGNAL LOG</h2>
95100
<script src="src/game-content.js"></script>
96101
<script src="src/progress.js"></script>
97102
<script src="src/feedback.js"></script>
103+
<script src="src/engagement.js"></script>
98104
<script>
99105
// ══════════════════════════════════════════════════════
100106
// SETUP
@@ -113,6 +119,7 @@ <h2 id="journal-title">SIGNAL LOG</h2>
113119
const journalList = document.getElementById('journal-list');
114120
const journalClose = document.getElementById('journal-close');
115121
const soundButton = document.getElementById('sound-button');
122+
const shareButton = document.getElementById('share-button');
116123

117124
const PX = 3;
118125
const TILE = 16;
@@ -166,15 +173,22 @@ <h2 id="journal-title">SIGNAL LOG</h2>
166173
} = LittleAndroidLogic;
167174
const world = createWorldMap(77);
168175
const MW = world.width, MH = world.height, map = world.map;
169-
const signalFragments = LittleAndroidContent.SIGNAL_FRAGMENTS.map(fragment => ({
170-
...fragment,
176+
const dailyChallenge = LittleAndroidEngagement.createDailyChallenge(
177+
new Date(),
178+
LittleAndroidContent.SIGNAL_FRAGMENTS.map(fragment => fragment.id),
179+
);
180+
const fragmentContent = new Map(LittleAndroidContent.SIGNAL_FRAGMENTS.map(fragment => [fragment.id, fragment]));
181+
const signalFragments = dailyChallenge.order.map(id => ({
182+
...fragmentContent.get(id),
171183
discovered: false,
172184
collected: false,
173185
}));
174186
let progressStorage = null;
175187
try { progressStorage = window.localStorage; } catch {}
176188
const savedProgress = LittleAndroidProgress.loadProgress(progressStorage);
177189
const feedback = LittleAndroidFeedback.createFeedbackController(window, progressStorage);
190+
const metrics = LittleAndroidEngagement.createLocalMetrics(progressStorage);
191+
metrics.increment('sessions');
178192
for (const fragment of signalFragments) {
179193
fragment.discovered = savedProgress.discoveredFragments.includes(fragment.id);
180194
fragment.collected = savedProgress.collectedFragments.includes(fragment.id);
@@ -262,6 +276,7 @@ <h2 id="journal-title">SIGNAL LOG</h2>
262276
player.moveToX = tx; player.moveToY = ty;
263277
player.moveProgress = 0;
264278
player.idleTimer = 0;
279+
metrics.markOnce('first_move');
265280
}
266281

267282
function drawUnit01(sx, sy, facing, walkFrame, moving, idleCoreOn, interacting) {
@@ -647,6 +662,7 @@ <h2 id="journal-title">SIGNAL LOG</h2>
647662
triggerNPCInteraction(npc);
648663
rememberNPC(npc);
649664
feedback.play('interact');
665+
metrics.markOnce('first_interaction');
650666
const dialogue = LittleAndroidContent.NPC_DIALOGUE[npc.type];
651667
showStatus(dialogue ? (quest.complete ? dialogue.complete : dialogue.active) : `LINK: ${npc.scanLabel || npc.type.toUpperCase()}`, 3.5);
652668
}
@@ -934,8 +950,9 @@ <h2 id="journal-title">SIGNAL LOG</h2>
934950

935951
function updateQuestUI() {
936952
const collected = signalFragments.filter(fragment => fragment.collected).length;
937-
questEl.textContent = quest.complete ? 'FACTORY ONLINE' : `SIGNALS ${collected}/${signalFragments.length}`;
953+
questEl.textContent = quest.complete ? `FACTORY ONLINE · ${dailyChallenge.code}` : `SIGNALS ${collected}/${signalFragments.length} · ${dailyChallenge.code}`;
938954
questResetButton.classList.toggle('show', quest.complete);
955+
shareButton.classList.toggle('show', quest.complete);
939956
}
940957

941958
function currentProgress() {
@@ -981,6 +998,7 @@ <h2 id="journal-title">SIGNAL LOG</h2>
981998
showStatus('FACTORY LINK RESTORED', 4);
982999
feedback.play('complete');
9831000
feedback.haptic([25, 35, 50]);
1001+
metrics.increment('completions');
9841002
} else {
9851003
showStatus(`${fragment.name} RECOVERED · ${collected}/${signalFragments.length}`, 2.5);
9861004
feedback.play('collect');
@@ -1004,6 +1022,7 @@ <h2 id="journal-title">SIGNAL LOG</h2>
10041022
updateQuestUI();
10051023
updateJournal();
10061024
showStatus('SIGNAL SEARCH RESTARTED', 2);
1025+
metrics.increment('replays');
10071026
}
10081027

10091028
function performAction() {
@@ -1040,6 +1059,28 @@ <h2 id="journal-title">SIGNAL LOG</h2>
10401059
if (!muted) feedback.play('interact');
10411060
});
10421061

1062+
async function shareCompletion() {
1063+
const text = LittleAndroidEngagement.completionText(dailyChallenge.code);
1064+
metrics.increment('share_intent');
1065+
try {
1066+
if (window.navigator && typeof window.navigator.share === 'function') {
1067+
await window.navigator.share({ title: 'Little Android', text, url: 'https://littleandroid.com' });
1068+
showStatus('COMPLETION SHARED', 2);
1069+
return;
1070+
}
1071+
if (window.navigator && window.navigator.clipboard) {
1072+
await window.navigator.clipboard.writeText(text);
1073+
showStatus('RESULT COPIED', 2);
1074+
return;
1075+
}
1076+
} catch {
1077+
showStatus('SHARE CANCELLED', 2);
1078+
return;
1079+
}
1080+
showStatus(text, 4);
1081+
}
1082+
shareButton.addEventListener('click', shareCompletion);
1083+
10431084
let statusTimer = 0;
10441085
function showStatus(message, duration = 1.5) {
10451086
statusEl.textContent = message;
@@ -1085,6 +1126,7 @@ <h2 id="journal-title">SIGNAL LOG</h2>
10851126
player.idleTimer = 0;
10861127
showStatus('SCANNING...');
10871128
feedback.play('scan');
1129+
metrics.markOnce('first_scan');
10881130
return true;
10891131
}
10901132

scripts/check.cjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ if (!html.includes('src/progress.js')) {
2020
if (!html.includes('src/feedback.js')) {
2121
throw new Error('index.html must load the feedback module');
2222
}
23+
if (!html.includes('src/engagement.js')) {
24+
throw new Error('index.html must load the engagement module');
25+
}
2326
if (inlineScripts.length !== 1) {
2427
throw new Error(`Expected one inline runtime script, found ${inlineScripts.length}`);
2528
}
@@ -29,5 +32,6 @@ require('../src/game-logic.js');
2932
require('../src/game-content.js');
3033
require('../src/progress.js');
3134
require('../src/feedback.js');
35+
require('../src/engagement.js');
3236

3337
console.log('Static checks passed.');

src/engagement.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
(function exposeEngagement(root, factory) {
2+
const api = factory();
3+
if (typeof module === 'object' && module.exports) module.exports = api;
4+
if (root) root.LittleAndroidEngagement = api;
5+
})(typeof globalThis !== 'undefined' ? globalThis : this, function createEngagementApi() {
6+
'use strict';
7+
8+
const METRICS_KEY = 'littleandroid.metrics';
9+
10+
function hashString(value) {
11+
let hash = 2166136261;
12+
for (let index = 0; index < value.length; index++) {
13+
hash ^= value.charCodeAt(index);
14+
hash = Math.imul(hash, 16777619);
15+
}
16+
return hash >>> 0;
17+
}
18+
19+
function seededRandom(seed) {
20+
return function random() {
21+
seed |= 0;
22+
seed = seed + 0x6D2B79F5 | 0;
23+
let value = Math.imul(seed ^ seed >>> 15, 1 | seed);
24+
value = value + Math.imul(value ^ value >>> 7, 61 | value) ^ value;
25+
return ((value ^ value >>> 14) >>> 0) / 4294967296;
26+
};
27+
}
28+
29+
function createDailyChallenge(date, fragmentIds) {
30+
const dateKey = typeof date === 'string' ? date : date.toISOString().slice(0, 10);
31+
const seed = hashString(dateKey);
32+
const random = seededRandom(seed);
33+
const order = [...fragmentIds];
34+
for (let index = order.length - 1; index > 0; index--) {
35+
const swapIndex = Math.floor(random() * (index + 1));
36+
[order[index], order[swapIndex]] = [order[swapIndex], order[index]];
37+
}
38+
return {
39+
dateKey,
40+
code: `D${String(seed % 10000).padStart(4, '0')}`,
41+
order,
42+
};
43+
}
44+
45+
function parseMetrics(raw) {
46+
try {
47+
const parsed = JSON.parse(raw || '{}');
48+
const counters = {};
49+
for (const [name, value] of Object.entries(parsed.counters || {})) {
50+
if (Number.isFinite(value) && value >= 0) counters[name] = value;
51+
}
52+
return { counters };
53+
} catch {
54+
return { counters: {} };
55+
}
56+
}
57+
58+
function createLocalMetrics(storage) {
59+
let state;
60+
try { state = parseMetrics(storage && storage.getItem(METRICS_KEY)); }
61+
catch { state = { counters: {} }; }
62+
63+
function persist() {
64+
try {
65+
if (storage) storage.setItem(METRICS_KEY, JSON.stringify(state));
66+
} catch {}
67+
}
68+
69+
function increment(name) {
70+
state.counters[name] = (state.counters[name] || 0) + 1;
71+
persist();
72+
return state.counters[name];
73+
}
74+
75+
function markOnce(name) {
76+
if (state.counters[name]) return false;
77+
state.counters[name] = 1;
78+
persist();
79+
return true;
80+
}
81+
82+
return Object.freeze({
83+
increment,
84+
markOnce,
85+
snapshot: () => JSON.parse(JSON.stringify(state)),
86+
});
87+
}
88+
89+
function completionText(challengeCode) {
90+
return `I restored the Little Android factory (${challengeCode}). Explore it at https://littleandroid.com`;
91+
}
92+
93+
return Object.freeze({
94+
METRICS_KEY,
95+
hashString,
96+
createDailyChallenge,
97+
parseMetrics,
98+
createLocalMetrics,
99+
completionText,
100+
});
101+
});

tests/engagement.test.cjs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use strict';
2+
3+
const test = require('node:test');
4+
const assert = require('node:assert/strict');
5+
const {
6+
createDailyChallenge,
7+
parseMetrics,
8+
createLocalMetrics,
9+
completionText,
10+
} = require('../src/engagement.js');
11+
12+
function memoryStorage() {
13+
const values = new Map();
14+
return {
15+
getItem: key => values.get(key) || null,
16+
setItem: (key, value) => values.set(key, value),
17+
};
18+
}
19+
20+
test('daily challenges are deterministic and retain every fragment', () => {
21+
const ids = ['a', 'b', 'c'];
22+
const first = createDailyChallenge('2026-07-16', ids);
23+
const second = createDailyChallenge('2026-07-16', ids);
24+
assert.deepEqual(first, second);
25+
assert.deepEqual([...first.order].sort(), ids);
26+
assert.match(first.code, /^D\d{4}$/);
27+
});
28+
29+
test('local metrics count without storing event payloads', () => {
30+
const metrics = createLocalMetrics(memoryStorage());
31+
assert.equal(metrics.increment('sessions'), 1);
32+
assert.equal(metrics.increment('sessions'), 2);
33+
assert.equal(metrics.markOnce('first_scan'), true);
34+
assert.equal(metrics.markOnce('first_scan'), false);
35+
assert.deepEqual(metrics.snapshot(), { counters: { sessions: 2, first_scan: 1 } });
36+
assert.deepEqual(parseMetrics('{bad'), { counters: {} });
37+
});
38+
39+
test('completion copy contains only challenge and public URL', () => {
40+
assert.equal(
41+
completionText('D0042'),
42+
'I restored the Little Android factory (D0042). Explore it at https://littleandroid.com',
43+
);
44+
});

tests/runtime-smoke.test.cjs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const LittleAndroidLogic = require('../src/game-logic.js');
88
const LittleAndroidContent = require('../src/game-content.js');
99
const LittleAndroidProgress = require('../src/progress.js');
1010
const LittleAndroidFeedback = require('../src/feedback.js');
11+
const LittleAndroidEngagement = require('../src/engagement.js');
1112

1213
function memoryStorage() {
1314
const values = new Map();
@@ -53,12 +54,14 @@ test('runtime initializes and renders a frame', () => {
5354
'journal-list': element(),
5455
'journal-close': element(),
5556
'sound-button': element(),
57+
'share-button': element(),
5658
};
5759
const sandbox = {
5860
LittleAndroidLogic,
5961
LittleAndroidContent,
6062
LittleAndroidProgress,
6163
LittleAndroidFeedback,
64+
LittleAndroidEngagement,
6265
console,
6366
Date,
6467
Math,
@@ -97,7 +100,7 @@ test('runtime initializes and renders a frame', () => {
97100
({ complete: quest.complete, collected: signalFragments.filter(fragment => fragment.collected).length });
98101
`, sandbox);
99102
assert.deepEqual({ ...completion }, { complete: true, collected: 3 });
100-
assert.equal(elements.quest.textContent, 'FACTORY ONLINE');
103+
assert.match(elements.quest.textContent, /^FACTORY ONLINE · D\d{4}$/);
101104
});
102105

103106
test('NPC movement rejects a destination reserved earlier in the frame', () => {
@@ -112,9 +115,9 @@ test('NPC movement rejects a destination reserved earlier in the frame', () => {
112115
getBoundingClientRect: () => ({ left: 0, top: 0 }),
113116
addEventListener() {}, getContext: () => context2d,
114117
});
115-
const elements = { game: element(), coords: element(), hint: element(), emote: element(), status: element(), 'action-button': element(), quest: element(), 'quest-reset': element(), 'journal-button': element(), 'journal-dialog': element(), 'journal-list': element(), 'journal-close': element(), 'sound-button': element() };
118+
const elements = { game: element(), coords: element(), hint: element(), emote: element(), status: element(), 'action-button': element(), quest: element(), 'quest-reset': element(), 'journal-button': element(), 'journal-dialog': element(), 'journal-list': element(), 'journal-close': element(), 'sound-button': element(), 'share-button': element() };
116119
const sandbox = {
117-
LittleAndroidLogic, LittleAndroidContent, LittleAndroidProgress, LittleAndroidFeedback, console, Date, Math, setTimeout, clearTimeout,
120+
LittleAndroidLogic, LittleAndroidContent, LittleAndroidProgress, LittleAndroidFeedback, LittleAndroidEngagement, console, Date, Math, setTimeout, clearTimeout,
118121
document: { hidden: false, body: element(), getElementById: id => elements[id] },
119122
window: { innerWidth: 800, innerHeight: 600, devicePixelRatio: 1, localStorage: memoryStorage(), addEventListener() {} },
120123
requestAnimationFrame() {},
@@ -143,9 +146,9 @@ test('interaction waits for a moving NPC to finish its tile step', () => {
143146
getBoundingClientRect: () => ({ left: 0, top: 0 }), addEventListener() {},
144147
getContext: () => context2d,
145148
});
146-
const elements = { game: element(), coords: element(), hint: element(), emote: element(), status: element(), 'action-button': element(), quest: element(), 'quest-reset': element(), 'journal-button': element(), 'journal-dialog': element(), 'journal-list': element(), 'journal-close': element(), 'sound-button': element() };
149+
const elements = { game: element(), coords: element(), hint: element(), emote: element(), status: element(), 'action-button': element(), quest: element(), 'quest-reset': element(), 'journal-button': element(), 'journal-dialog': element(), 'journal-list': element(), 'journal-close': element(), 'sound-button': element(), 'share-button': element() };
147150
const sandbox = {
148-
LittleAndroidLogic, LittleAndroidContent, LittleAndroidProgress, LittleAndroidFeedback, console, Date, Math, setTimeout, clearTimeout,
151+
LittleAndroidLogic, LittleAndroidContent, LittleAndroidProgress, LittleAndroidFeedback, LittleAndroidEngagement, console, Date, Math, setTimeout, clearTimeout,
149152
document: { hidden: false, body: element(), getElementById: id => elements[id] },
150153
window: { innerWidth: 800, innerHeight: 600, devicePixelRatio: 1, localStorage: memoryStorage(), addEventListener() {} },
151154
requestAnimationFrame() {},

0 commit comments

Comments
 (0)