[EPIC] Unslop The Beacon — From Prototype to Quality #54
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
Full code audit performed 2026-04-10. Verdict: SLOPPY (2.4/5 average).
The game works and the design is excellent (4/5). But the implementation is a 2,688-line monolith with no tests, no modules, heavy duplication, magic numbers everywhere, fragile save/load, and tight coupling between logic and rendering. It reads like rapid prototyping that never got refactored.
This epic captures every finding and the required remediation. Each task is independently shippable.
Task 1: Extract Constants — Kill the Magic Numbers
Severity: Medium | Effort: Small
Every gameplay constant is buried as a literal in the code. Extract them to a
CONFIGobject at the top of the file (or a separateconfig.js).Specific magic numbers to extract:
game.js:1006-0.05 * wizardCountgame.js:10110.2 * wizardCountgame.js:10170.1 * wizardCountgame.js:10230.15 * wizardCountgame.js:10480.1game.js:10520.05game.js:11690.02game.js:12760, 10, 10game.js:1172.0game.js:25280.5game.js:263130000game.js:136-1430, 2000, 20000...Acceptance criteria:
CONFIGorCONSTobjectTask 2: Extract
getClickPower()— Kill the DuplicationSeverity: Medium | Effort: Small
The click power formula is copy-pasted in three places:
Fix: Single function
getClickPower()called from all three locations.Task 3: Consolidate Duplicate Deploy Projects
Severity: Low | Effort: Small
Two projects do the same thing:
p_deploy(line 361){trust: 5, compute: 500}totalCode >= 200 && totalCompute >= 100p_hermes_deploy(line 543){code: 500, compute: 300}totalCode >= 300 && totalCompute >= 150 && deployFlag === 0Both set
G.deployFlag = 1; G.phase = max(phase, 3).p_deploydoesn't checkdeployFlag, so it stays available forever.Fix: Merge into one project, or make
p_deploycheckdeployFlag === 0.Task 4: Fix Fragile Save/Load
Severity: High | Effort: Medium
Problem 1:
Object.assign(G, data)at line 2483 merges unvalidated JSON into game state. A crafted save file can inject arbitrary properties.Problem 2: Import validation at line 2404 is trivially weak:
if (!data.code && !data.totalCode && !data.buildings).Problem 3: Debuffs re-trigger their log messages and toasts on every load (line 2514).
Fix:
Task 5: Initialize
G.flags— Fix Undefined AccessSeverity: Low | Effort: Trivial
G.flagsis never initialized in the global state (line 7-133). It's created on-the-fly at line 380 (G.flags = G.flags || {}). Several places checkG.flags && G.flags.creativity(lines 314, 994, 1118, 1735) defensively.Fix: Add
flags: {}to the initial state object at line 7.Task 6: Add JSDoc to Core Functions
Severity: Low | Effort: Medium
Zero JSDoc comments in 2,688 lines. At minimum, document:
tick(dt)— main game loopwriteCode()— click handlerbuyBuilding(id)— purchase logicbuyProject(id)— project purchasesaveGame()/loadGame()— persistencefmt(n)/spellf(n)— number formattingupdateRates()— production calculationTask 7: Split game.js — Module Extraction
Severity: High | Effort: Large
The monolith must be broken up. Proposed split:
No build step needed — just multiple
<script>tags in order, sharing the global scope.Migration strategy: Extract in order: utils.js → data.js → render.js → engine.js. Each extraction is a standalone PR that doesn't break the game.
Task 8: Add Smoke Tests
Severity: Medium | Effort: Medium
Branches
feat/a11y-smoke-testandfix/add-smoke-testexist but were never merged. Start with:fmt()andspellf()— these are pure functions with clear inputs/outputsgetClickPower()(after Task 2 extracts it)Use a lightweight framework (Jest or even raw
assert()). Add atest/directory withnpm testor a simple HTML runner.Task 9: Clean Up Git Hygiene
Severity: Low | Effort: Small
burn/*after merge)feat:,fix:,refactor:,test:Task 10: Guardrails — CI Pipeline
Severity: High | Effort: Medium
Add automated quality gates to prevent regression:
no-unused-vars,no-undef,no-evalnode --check game.json every PRAdd a
.github/workflows/ci.yml(or Gitea equivalent) that runs on every PR to main.Task 11: Guardrails — PR Checklist
Severity: Low | Effort: Trivial
Add a
PULL_REQUEST_TEMPLATE.md:Execution Order
Definition of Done
G.flagsinitialized in stateIndependent Review — Claude (Opus 4.6)
I read
game.jsend-to-end and cross-checked the audit. The existing findings (monolith, duplicated click power, unwhitelisted save/load, uninitializedG.flags, duplicate deploy projects) all hold up. Notes and additions below — a couple are real bugs that deserve to jump the queue.Bugs the audit under-weighted or missed
1.
community_dramacompound-decayscodeBoostevery tick (P0 bug).At
game.js:1474:applyFnis invoked fromupdateRates()on every tick.G.codeRateis reset each tick so the other debuffs are fine (runner_stuck,api_rate_limit,memory_leak, etc.), butcodeBoostis a persistent multiplier. If community_drama ever fires,codeBoostdecays exponentially toward zero until the debuff is cleared — and even after clearing, the decay is never undone. Silent progression-breaking bug. This should be fixed before the Phase 3 refactor work, independent of it.2. Sprint × offline-gains exploit.
When
G.sprintActiveis true,G.codeBoosthas the 10× multiplier baked in.saveGame()persists it in that state. On reload,loadGame()copiescodeBoostback viaObject.assign, then callsupdateRates(), then computes offline gains asG.codeRate * offSec * 0.5(game.js:2529). Result: all offline seconds are credited at 10× even if the sprint had only ~2 s left at save time. Free infinite resources via save-close-reload. Fix: either unbake the sprint multiplier before saving, or cap offline credit at the remaining sprint duration.3.
resolveCostis duplicated between theEVENTSdefinition and theactiveDebuffspush.Each event literal declares
resolveCosttwice — once on the event def (e.g.game.js:1342) and again when constructing the debuff object (game.js:1349). They can and will drift. Single source of truth wanted.4. Save/load whitelist asymmetry.
saveGame()uses an explicit whitelist;loadGame()usesObject.assign(G, data)(game.js:2483). Aside from being the injection surface the audit flagged, the asymmetry silently loses state: fields added toGlater that nobody remembered to add tosaveGame's whitelist will reset on every reload. Concrete examples already in the code:maxCode/maxCompute/...,G.tick,G.comboCount,G.totalClicksis saved butG.totalRescuesis saved — check every field. Task 5 in the plan should also require a save/load round-trip test that asserts equality ofGbefore save and after load.5. Click power duplication is actually three sites, not "three locations" as a rough count — one of them (Swarm Protocol at
game.js:1065) is in a hot path insideupdateRates()and silently uses its own re-derivation of the formula. Easy to miss when renaming or extending.Smaller observations
G.flags.creativity(sub-object) sits alongside ~14 top-level*Flagproperties (deployFlag,sovereignFlag,beaconFlag,pactFlag,mempalaceFlag,ciFlag,branchProtectionFlag,nightlyWatchFlag,nostrFlag,lazarusFlag,strategicFlag,swarmFlag,memoryFlag,milestoneFlag). Task 1 should fold the top-level flags intoG.flagstoo, not just addG.flags = {}at init. Otherwise you're just formalizing the inconsistency.p_deploy/p_hermes_deploy.p_the_pact(game.js:503) andp_the_pact_early(game.js:647) also both setG.pactFlag = 1with different side-effects — this is arguably intentional (early vs late pact is a design choice), but the player can't purchase both; the code doesn't model the branch, it just races whichever triggers first. Worth deciding explicitly.BDEF.find(b => b.id === id)is called on everygetBuildingCost/canAffordBuilding/spendBuildingcall — cheap to replace with aMaponce, but premature to optimize. Flagging only because Task 8 (module split) is the right moment.entry.innerHTML = \<span...>${msg}`** inlog()(game.js:2114) is an XSS foot-gun. All current callers pass static strings, so no live risk, but any future dynamic field (player name, imported save metadata) becomes an injection vector. Cheap to switch totextContent` for the message body now.p_wire_budgetis markedrepeatablewith triggerG.compute < 1, which means it re-entersactiveProjectsevery time compute dips below 1. Minor UI churn, not a bug.G.driftis monotonic. No redemption path once drift is taken. Probably intentional (the whole "Drift" metaphor), but worth documenting as a design decision so nobody "fixes" it later.Recommended reprioritization
The existing 11-task plan is sound. The only change I'd push for is promoting a new P0 "bugfix sweep" to run before Phase 1:
community_dramacompoundingcodeBoostresolveCostto a single source per eventThese are small, localized, and don't need the CONFIG/module work to land. They're also the kind of silent bugs that get harder to spot once the refactor starts moving code around.
After that, the existing Phase 1 → 4 ordering is good. I'd strengthen Task 5's acceptance criteria to include a save → load → deep-equal round-trip test so the save whitelist stays in sync with
G.Overall the audit is accurate and the plan is the right shape. The code is more bug-prone than "2/5 implementation" implies once you look closely at mutation patterns in the debuff system, but the fixes are small and the design (4/5) is doing most of the work keeping this readable at 2.7k lines.
— Reviewed against
main @ 68ee648, comparinggame.jslines cited above.