Turn GaugeGap into a twelve-lab science game#90
Conversation
There was a problem hiding this comment.
Code Review
This pull request expands the GaugeGap science arcade from eight to twelve live experiments by adding four new interactive labs (Ant Trail, Traffic Tamer, Life Painter, and Ecosystem Keeper) and introducing a gamified arcade passport system to track XP, levels, daily missions, streaks, and achievements. Feedback on these changes focuses on enhancing UX and performance: suggestions include using local time instead of UTC for consistent daily streak tracking, dynamically scaling the ant population without resetting the simulation, updating the Life Painter scoreboard on every step to prevent visual lag, optimizing distance calculations in the Ecosystem Lab using squared distances, and refactoring the entity update loop to resolve array mutation and performance issues.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| function dateKey(date = new Date()) { | ||
| return date.toISOString().slice(0, 10); | ||
| } |
There was a problem hiding this comment.
Using date.toISOString() returns the date in UTC. This can cause daily streaks and missions to reset or fail to increment unexpectedly depending on the user's local timezone (e.g., if a user plays twice on consecutive local days but those visits fall on the same or non-consecutive UTC days). Formatting the date string using local time ensures consistent daily streak behavior.
function dateKey(date = new Date()) {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}| function render() { | ||
| if (lastReset !== resetRef.current || antsRef.current.length !== controlsRef.current.count) { | ||
| lastReset = resetRef.current; | ||
| seedWorld(); | ||
| } |
There was a problem hiding this comment.
Resetting the entire simulation (clearing placed obstacles and resetting deliveries to 0) when the user simply adjusts the "Ants" slider is a frustrating user experience. Instead, the ant population should be dynamically scaled up or down without resetting the world state.
| function render() { | |
| if (lastReset !== resetRef.current || antsRef.current.length !== controlsRef.current.count) { | |
| lastReset = resetRef.current; | |
| seedWorld(); | |
| } | |
| function render() { | |
| if (lastReset !== resetRef.current) { | |
| lastReset = resetRef.current; | |
| seedWorld(); | |
| } else if (antsRef.current.length !== controlsRef.current.count) { | |
| const target = controlsRef.current.count; | |
| if (antsRef.current.length < target) { | |
| const extra = makeAnts(target - antsRef.current.length, nest, Date.now() % 100000); | |
| antsRef.current = [...antsRef.current, ...extra]; | |
| } else { | |
| antsRef.current = antsRef.current.slice(0, target); | |
| } | |
| } |
| gridRef.current = next; generationCount += 1; | ||
| if (generationCount % 4 === 0) { let count = 0; for (let i = 0; i < next.length; i += 1) count += next[i]; setAlive(count); setGeneration(generationCount); } |
There was a problem hiding this comment.
Updating the scoreboard only every 4 generations causes a visual mismatch when the simulation is paused or running slowly (e.g., if paused at generation 11, the scoreboard remains stuck at generation 8). Since counting the alive cells and updating the state is extremely fast, doing it on every step ensures the scoreboard is always accurate and in sync with the canvas.
| gridRef.current = next; generationCount += 1; | |
| if (generationCount % 4 === 0) { let count = 0; for (let i = 0; i < next.length; i += 1) count += next[i]; setAlive(count); setGeneration(generationCount); } | |
| gridRef.current = next; generationCount += 1; | |
| let count = 0; | |
| for (let i = 0; i < next.length; i += 1) count += next[i]; | |
| setAlive(count); | |
| setGeneration(generationCount); |
| function nearest(entity, type, radius) { | ||
| let best = null; let bestDistance = radius; | ||
| entitiesRef.current.forEach((candidate) => { if (candidate.type !== type || candidate === entity) return; const distance = Math.hypot(candidate.x - entity.x, candidate.y - entity.y); if (distance < bestDistance) { best = candidate; bestDistance = distance; } }); | ||
| return best; | ||
| } |
There was a problem hiding this comment.
Math.hypot is relatively slow in hot loops. Since nearest is called for every non-plant entity against all other entities every frame, comparing squared distances instead of actual distances avoids the expensive square root calculation and significantly improves performance, especially on mobile devices.
function nearest(entity, type, radius) {
let best = null;
let bestDistSq = radius * radius;
const entities = entitiesRef.current;
for (let i = 0; i < entities.length; i++) {
const candidate = entities[i];
if (candidate.type !== type || candidate === entity) continue;
const dx = candidate.x - entity.x;
const dy = candidate.y - entity.y;
const distSq = dx * dx + dy * dy;
if (distSq < bestDistSq) {
best = candidate;
bestDistSq = distSq;
}
}
return best;
}| const removed = new Set(); | ||
| entitiesRef.current.forEach((entity, index) => { | ||
| entity.age += 1; | ||
| if (entity.type === 'plant') { entity.energy = Math.min(4, entity.energy + 0.002 * controls.sunlight); reproduce(entity, entity.energy > 2.8 ? 0.0015 * controls.sunlight : 0); return; } | ||
| const targetType = entity.type === 'prey' ? 'plant' : 'prey'; | ||
| const target = nearest(entity, targetType, entity.type === 'prey' ? 90 : 125); | ||
| if (target) { const dx = target.x - entity.x; const dy = target.y - entity.y; const distance = Math.max(1, Math.hypot(dx, dy)); entity.vx += dx / distance * (entity.type === 'prey' ? 0.045 : 0.06); entity.vy += dy / distance * (entity.type === 'prey' ? 0.045 : 0.06); if (distance < 8) { removed.add(entitiesRef.current.indexOf(target)); entity.energy += entity.type === 'prey' ? 15 : 24; } } | ||
| entity.vx += (random() - 0.5) * 0.08; entity.vy += (random() - 0.5) * 0.08; | ||
| const max = entity.type === 'prey' ? 1.65 : 1.95; const magnitude = Math.max(0.01, Math.hypot(entity.vx, entity.vy)); if (magnitude > max) { entity.vx = entity.vx / magnitude * max; entity.vy = entity.vy / magnitude * max; } | ||
| entity.x = (entity.x + entity.vx * controls.speed + width) % width; entity.y = (entity.y + entity.vy * controls.speed + height) % height; | ||
| entity.energy -= (entity.type === 'prey' ? 0.022 : 0.035) * controls.metabolism * controls.speed; | ||
| if (entity.energy <= 0 || entity.age > 12000) removed.add(index); | ||
| reproduce(entity, entity.energy > (entity.type === 'prey' ? 62 : 85) ? 0.004 : 0); | ||
| }); | ||
| entitiesRef.current = entitiesRef.current.filter((_, index) => !removed.has(index)); |
There was a problem hiding this comment.
This entity update loop has three issues:
- Appending new entities directly to
entitiesRef.currentviareproduceinside theforEachloop is fragile and can lead to unexpected behaviors. - Calling
indexOf(target)inside the loop introduces an$O(N^2)$ complexity, which is inefficient. - Storing indices in the
removedSet is fragile compared to storing the object references directly.
Refactoring the loop to collect newborns in a separate array and using object references for removal resolves all three issues.
const removed = new Set();
const newborns = [];
entitiesRef.current.forEach((entity) => {
entity.age += 1;
if (entity.type === 'plant') {
entity.energy = Math.min(4, entity.energy + 0.002 * controls.sunlight);
if (entity.energy > 2.8 && Math.random() < 0.0015 * controls.sunlight && entitiesRef.current.length + newborns.length < 420) {
entity.energy *= 0.58;
newborns.push(makeEntity(entity.type, entity.x + (random() - 0.5) * 14, entity.y + (random() - 0.5) * 14, random));
}
return;
}
const targetType = entity.type === 'prey' ? 'plant' : 'prey';
const target = nearest(entity, targetType, entity.type === 'prey' ? 90 : 125);
if (target) {
const dx = target.x - entity.x;
const dy = target.y - entity.y;
const distance = Math.max(1, Math.hypot(dx, dy));
entity.vx += dx / distance * (entity.type === 'prey' ? 0.045 : 0.06);
entity.vy += dy / distance * (entity.type === 'prey' ? 0.045 : 0.06);
if (distance < 8) {
removed.add(target);
entity.energy += entity.type === 'prey' ? 15 : 24;
}
}
entity.vx += (random() - 0.5) * 0.08;
entity.vy += (random() - 0.5) * 0.08;
const max = entity.type === 'prey' ? 1.65 : 1.95;
const magnitude = Math.max(0.01, Math.hypot(entity.vx, entity.vy));
if (magnitude > max) {
entity.vx = entity.vx / magnitude * max;
entity.vy = entity.vy / magnitude * max;
}
entity.x = (entity.x + entity.vx * controls.speed + width) % width;
entity.y = (entity.y + entity.vy * controls.speed + height) % height;
entity.energy -= (entity.type === 'prey' ? 0.022 : 0.035) * controls.metabolism * controls.speed;
if (entity.energy <= 0 || entity.age > 12000) {
removed.add(entity);
} else if (entity.energy > (entity.type === 'prey' ? 62 : 85) && Math.random() < 0.004 && entitiesRef.current.length + newborns.length < 420) {
entity.energy *= 0.58;
newborns.push(makeEntity(entity.type, entity.x + (random() - 0.5) * 14, entity.y + (random() - 0.5) * 14, random));
}
});
entitiesRef.current = entitiesRef.current.filter((entity) => !removed.has(entity)).concat(newborns);There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7d9353a03d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const values = (query.get('state') || '').split(',').map(Number); | ||
| return values.every(Number.isFinite) ? values : defaults; |
There was a problem hiding this comment.
Require complete state before overriding lab defaults
When a user opens a new lab from the selector, daily mission, or landing card, the URL is set to ?lab=<id> while state is deliberately removed. With an empty state, ''.split(',').map(Number) becomes [0], passes this every(Number.isFinite) check, and prevents the defaults from being used; the remaining parameters become undefined/NaN (for example ants get smell/evaporation as NaN, traffic gets a NaN signal cycle, and the copied helper in WorldPlaygrounds.jsx breaks ecosystem speed/metabolism). Please reject missing or wrong-length state values before returning URL data.
Useful? React with 👍 / 👎.
What this adds
Expands GaugeGap from eight simulations into a twelve-lab science arcade and adds a local progression loop.
New tactile experiments
Retention and game layer
Scientific framing
Each new lab includes a mission, live score, interactive controls, underlying-model explanation and shareable URL settings. Existing BrainSNN, research tools and the first eight labs remain intact.
Validation requested