E
Eugene Kozlovsky
Guest
Build It. Ship It. The Physical Reality of 8 GB RAM and Air-Raid Sirens
NICHLYST is an anti-dopamine archival survival sim built for RevenueCat's Shipaton 2026: forty days in the basement archive of Myrne, a fictional city in a fictional besieged Eastern European state, one archivist, visitors who may or may not be telling the truth, 33 endings, an interface that physically rots as the story collapses. No combo meters, no login rewards, no energy timers. The thesis: attention is a resource, and most mobile games strip-mine it. NICHLYST rations it the way its own shelter rationed fuel and food — in strict, finite portions, every spend accounted for in the game state's actual resource ledger.
The physical context matters. Built in September 2026 in wartime Ukraine on an aging AMD A4 with 8 GB of RAM, in a city where civil defense sirens sound most days but where you do not run to a shelter each time — if only because you would live there — the air-raid siren is your rhythm. When it wails, the work continues unless a strike is close enough to matter. When it becomes just another ambient sound, you keep at it, wishing a stray shrapnel fragment will not come through the window. When the strike is heavy, you wait out the worst in the hallway. Nothing can require a long-running toolchain, an emulator, or a hot-reload server eating 4 GB before the first render. Unity, React Native, Flutter — out, each a multi-gigabyte runtime asking a machine that cannot afford the question.
The answer was subtraction: the entire engine is Vanilla JS (ES6+), CSS3 custom properties, and HTML5, wrapped in Apache Capacitor for Android — no build step worth mentioning, no virtual DOM reconciling a game loop. The full narrative payload is not a single monolithic file. The manifest-driven loader (
www/js/init.js) fetches meta, codex, and endings plus eight modular day chunks under data/days/ (prologue, act_1 through act_4, postscript, hidden_postscript, after_end) — each a small JSON file loaded in parallel once at boot and kept resident, with a fallback bundled game_data.json in case the modularity breaks. The browser engine is the runtime, and it is already on the phone.Proof of usefulness is a shipping artifact. NICHLYST's Devpost submission for Shipaton 2026 is penciled in ahead of the September 30, 2026 deadline, with Google Play's mandatory 14-day Closed Testing track running now with thirteen physical testers across time zones, playing builds from a Redmi Note 9 Pro upward while rockets were a notification category, not a metaphor. The deadline is September 30; this article ships September 23, because HackerNoon's editorial queue takes 3-5 business days, and a miss on timing is indistinguishable from a miss on quality.
Pivotal Design Decisions: Accessibility, UI Decay, and Git-Enforced Aesthetics
Design here is engineering, and the most contested artifact — a color palette — is enforced by a pre-commit Git hook.
The aesthetic is a basement archive: desaturated ochre-rust-grey over near-black. The palette is five colors and nothing else: #1A1C1C, #2D3131, #7D7263, #BCB2A1, #EDE6D8. Ink (#EDE6D8) over background (#1A1C1C) yields a 13.78:1 contrast ratio — WCAG AAA by a wide margin. The palette lives as CSS custom properties (
--c-1 through --c-5) in a single source of truth, and every screen consumes tokens, never raw hex. Choice buttons guarantee 48px touch targets, layouts respect env(safe-area-inset-*) for notched phones, and every animated effect degrades to none under prefers-reduced-motion.You will get sloppy at 2 a.m., air-raid siren or no air-raid siren, and inertia will wave an off-palette color through if nothing stops it; an automated gate has no inertia. The palette is enforced by
scripts/check_colors.sh, a 53-line pre-commit hook that diffs the staged file list, scans CSS, JS, and HTML for hex literals, and validates each against a whitelist regex. Any off-palette color halts the commit:
Code:
ALLOWED_PATTERN="^#((1a1c1c|2d3131|7d7263|bcb2a1|ede6d8)([0-9a-fA-F]{2})?)$"
FILES_TO_CHECK=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null)
HEX_MATCHES=$(grep -onE "#[0-9a-fA-F]{3,8}\b" "$file" 2>/dev/null)
if ! echo "$HEX_LOWER" | grep -qE "$ALLOWED_PATTERN"; then
echo "[STOP-LOSS] Unauthorized color '$HEX' at $file:$LINE"
EXIT_CODE=1
fi
# On violation, the hook exits 1, and Git rejects the commit:
exit 1
The pattern accepts 6-digit and 8-digit (alpha-channel) hex, case-insensitively, and nothing else. It self-polices: binaries, fonts, audio,
www/dist, and the hook itself are excluded. The repository version logs violations in Ukrainian with severity markers; the lines above are the same logic, log text sanitized for print. The load-bearing part is the failure mode: exit 1 in a pre-commit hook is a hard stop. Aesthetics became an invariant of version control — the gate has caught off-palette hex during late-night edits, exactly when a human reviewer would have waved it through.The second design system is decay. The interface has 8 levels of visual degradation, driven by a
currentDecayLevel that climbs from 0 to 7 as the archive collapses over the 40 days: resource bars fade toward invisibility, borders thicken, ink dims toward ghost tones. The UI is a narrative instrument — the player watches the same archive rot the archivist does — and the rot is tokenized, not hand-tinted, which is exactly why the color hook can police it.
Postmortem 1: Defeating Combinatorial State-Space Explosion with a Strict State Machine
What almost failed was arithmetic: 40 days, 33 endings, 130 boolean flags, 6 moral axes — a state space where any unguarded mutation is a load-bearing bug found three weeks later. The naive pattern: a global mutable
window.gameState every module writes to produces two failure classes: races when async systems interleave, and corrupted saves when a resource drifts out of bounds mid-write.The breakthrough in
www/js/state_manager.js is a state machine that makes illegal states unrepresentable:
Code:
class StateManager {
#resources = {};
#flags = {};
#moralLedger = { truth: 0, mercy: 0, preservation: 0,
complicity: 0, memory: 0, survival: 0 };
modifyResource(key, delta) {
const config = this.#gameData.global_resources[key];
if (!config) return;
const current = this.#resources[key] ?? config.start;
this.#resources[key] =
Math.max(config.min, Math.min(config.max, current + Number(delta)));
this.autoSave();
}
get resources() { return Object.freeze({ ...this.#resources }); }
getStateSnapshot() {
return { resources: this.resources, flags: this.flags,
archive: this.archive, currentDayId: this.#currentDayId,
moralLedger: this.moralLedger };
}
}
Three mechanisms carry the weight. First, ES6
#private fields make the true state objects unreachable from outside; every access routes through a getter or mutation method, so there is exactly one writer in the application. Second, every mutation clamps: modifyResource forces results into the schema's min/max via Math.max/Math.min, the moral ledger via [-100, 100]; no delta, however hostile or race-duplicated, escapes range. Third, getStateSnapshot() hands the UI a shallow copy wrapped in Object.freeze() — the renderer reads but cannot mutate.The loader validates restored resources against the schema, clamps them into range, flags tampered saves rather than crashing. Saves are delegated to a SaveManager: debounced writes, HMAC-SHA256 signing, a skip-if-unchanged JSON comparison. The explosion was not defeated by cleverness, but by making the blast radius of any bug one clamped number. The trade-off is real — 130 flags behind private getters add ceremony — but an invariant that cheap is the cheapest insurance an eight-gigabyte machine ever bought.
Structurally, the canon flows through eight psychological waves — guarded phases inside the same invariant container — terminating in a deterministic evaluation that maps the clamped ledger onto exactly 33 canonical endings, never more, never fewer:
Code:
+----------------------------------------------------------------------+
| INVARIANT STATE CONTAINER (#private) |
| [W1: Awakening] -> [W2: Ruptures] -> [W3: Net] -> [W4: Raid] |
| | |
| [W8: Afterlife] <- [W7: Drift] <- [W6: Residue] <- [W5: Ash] |
| | |
| +---> [DETERMINISTIC EVALUATION] ---> 33 CANONICAL ENDINGS |
| (Clamped Deltas: 0..100 | Frozen UI Snapshots) |
+----------------------------------------------------------------------+
Postmortem 2: Eliminating Mobile WebView Jank with Audio Pooling
What failed was sound — and closed testing found it, not a profiler. On Day 2 of the 14-day window, Tester #1 (Sanjay, on a Redmi Note 9 Pro, the cohort's lowest-spec device) reported that "the story moments stutter when sounds stack up." That report was the diagnosis: knock motif, caption, and ambient crossfade within one second dropped frames below 60fps. The profile was textbook GC stutter — every one-shot sound allocated a fresh
Audio element, played it, dropped the reference, and let the WebView's collector sweep the corpses mid-frame. Only a physical tester on real hardware could have surfaced it; the desktop build never dropped a frame.The fix in
www/engine/AudioManager.js is an object pool: pre-allocate 4 HTMLAudioElement instances once, then cycle round-robin forever:
Code:
const MOTIF_POOL_SIZE = 4;
let _motifPool = [];
let _motifPoolIndex = 0;
let _motifPoolReady = false;
const _initMotifPool = () => {
if (_motifPoolReady) return;
_motifPool = [];
for (let i = 0; i < MOTIF_POOL_SIZE; i++) {
const audio = new Audio();
audio.preload = 'auto';
audio.dataset.poolSlot = String(i); // debug marker
_motifPool.push(audio);
}
_motifPoolReady = true;
};
// In playMotif(): round-robin through the pool
const audio = _motifPool[_motifPoolIndex];
_motifPoolIndex = (_motifPoolIndex + 1) % MOTIF_POOL_SIZE;
if (!audio.paused) { audio.pause(); audio.currentTime = 0; }
audio.src = motifData.path;
audio.volume = playVolume;
audio.play().catch(() => {});
Zero allocations per playback. The fix shipped in the next build; Tester #1 re-verified the same beats clean. Captions reuse one element instead of rebuilding. Rendering holds the line — all narrative text is written via
textContent and createTextNode, never innerHTML, closing the injection surface between story data and DOM in one stroke.Ethical Monetization as System Architecture: The RevenueCat Integration
Monetization is a system design problem, and the answer is one non-consumable:
nichlyst_full_game at $4.99. No timers, no ads, no tracking scripts. Days 1-3 are a free atmospheric prologue; at the Day 4 boundary, the blast door locks with a diegetic line — "The Archive demands a key" — the paywall is the lock, not an interruption of the game.
The deeper decision was Zero-Backend. The textbook path for Google Play purchases is a server of your own: receive the purchase token, verify it server-side against the store API, manage a service-account key, run a Pub/Sub listener for Real-Time Notifications, keep it reachable 24/7. For a studio, a week of work. For a solo builder living in a country where strikes on energy infrastructure can cut power to a neighborhood without warning, a fatal single point of failure: when the grid drops — no rolling schedule, just a strike, an accident, an outage nobody scheduled — players cannot unlock or restore, and paid customers become support tickets generated by power cuts.
RevenueCat eliminated that failure surface:
@revenuecat/purchases-capacitordelegates receipt verification to RevenueCat's edge infrastructure — cryptographically signed entitlements checked against Google Play, no server owned — and caches entitlement state on-device, so an unlocked player stays unlocked through any outage. Entitlement reads need no network; only the first purchase and first restore touch connectivity. Zero servers, zero keys to rotate, zero uptime to defend. The most resilient backend is no backend.The verification loop, offline-resilient by construction:
Code:
[Client: Day 4 Paywall] --(Offline Cache)--> [Immediate Local Entitlement]
| ^
(Network Restored) |
v |
[RevenueCat SDK] ---(Encrypted Token)---> [RevenueCat Edge / Google Play]
Configuration lives in
www/js/rc_config.js, where every identifier is data, and the API key arrives from injected environment variables (a literal REPLACE_ME token in a release build means the env step failed — checkable at a glance):
Code:
window.RC_CONFIG = {
publicKey: window.__ENV__?.REVENUECAT_ANDROID_PUBLIC_KEY || 'goog_REPLACE_ME',
products: { fullGame: 'nichlyst_full_game' },
entitlements: { fullGame: 'full_game' },
offering: { id: 'default' },
freeContentBoundary: 'end_of_day_3',
};
The overlay in
www/js/paywall.js never hardcodes product or entitlement IDs; it reads them from that config. Entitlement verification is one cached predicate over RevenueCat's customer info:
Code:
const _updateEntitlement = (customerInfo) => {
const entitlements = customerInfo.entitlements || {};
const active = entitlements.active || {};
_unlocked = !!active[entitlementIdentifier()]; // 'full_game'
};
The purchase flow calls
purchasePackage, re-checks the entitlement from the returned customerInfo rather than trusting success, and confirms with a medium haptic before unlocking. The recovery flow is Purchases.restorePurchases() — mandatory for any store-compliant non-consumable:
Code:
plugin.restorePurchases().then((result) => {
_updateEntitlement(result.customerInfo || result);
if (_unlocked) { _destroy(); _onUnlockedCb(); }
else { _setStatus('No previous purchases found.'); }
});
Offline behavior is graceful by construction: without connectivity, the overlay reports it and offers the prologue path, resetting
currentDayId to 1 and saving rather than stranding the player. Cached entitlements keep the playthrough offline-legitimate — the unlock decision is local, backed by last verified customer info. The paywall is accessible: role="dialog" with aria-modal, an aria-live="polite" region, back-button handling, Escape-to-close.Synthesis: Antifragility Through Subtraction (Via Negativa)
Every system in this postmortem is a removal. Remove the framework, and the 8 GB machine becomes sufficient. Remove mutable global state, and corrupted saves become impossible by construction. Remove per-sound allocation, and GC jank disappears. Remove every color but five, and Git enforces aesthetics. Remove the backend, and a wartime power grid stops mattering to your purchase flow. Remove predatory mechanics, and monetization reduces to one honest transaction verified by one entitlement check.
The heuristics for solo builders, distilled:
- Let physical limits dictate mechanical elegance; the AMD A4 and the air-raid sirens were the architecture.
- Automate the checks a tired human at 2 a.m. will wave through; a 53-line hook outperforms any style guide.
- Make illegal states unrepresentable, then stop worrying about them.
- Treat testers on real hardware as your profiler; one report from Tester #1 beat any benchmark.
- The most resilient server is no server; verify on infrastructure that does not share your grid.
- Subtraction scales. Dependencies do not.
NICHLYST's Devpost submission for Shipaton 2026 goes in ahead of the September 30 deadline, with its Google Play Closed Testing track running now. Whatever the judges decide, the artifact proves its usefulness: it exists, it runs at 60fps on a Redmi Note 9 Pro, its narrative payload lives in manifest-driven modular JSON chunks that load once, its palette is exactly five colors, and its commits cannot lie about it.
Build under pressure. Ship before the deadline. Subtract until it works.