DEV Community

Cover image for Building NICHLYST: Eight Waves, One Canon — How 40 Days of Script Survived a Strict State Machine
Eugene Kozlovsky
Eugene Kozlovsky

Posted on Originally published at dev.to AI-assisted

Building NICHLYST: Eight Waves, One Canon — How 40 Days of Script Survived a Strict State Machine

It is Monday, September 21, 2026. Day 11 of the 14-day Google Play closed testing anabiosis. The clock runs on Google's terms, not mine. Outside the window in Kyiv, the air siren has just ended its third cycle of the morning. Inside, on an aging AMD A4 laptop with 4 GB of RAM, the terminal holds its breath. The bundle compiled. The state machine holds. There is nothing to add today, and that restraint is the only reason the system still works.

This devlog is about the architecture that kept NICHLYST from consuming itself.


The Branching Trap

Narrative games with branching paths face a problem that most builders underestimate until it is too late. You write a day. You give the player two choices. Each choice sets a flag. The next day reads that flag and branches again. After ten days, you have 1,024 possible states. After twenty days, the number exceeds one million. After forty days with 130 flags, six moral axes, and 33 distinct endings, you are no longer writing a story. You are constructing a state-space explosion that will crush any naive implementation.

The naive implementation is always the same: a plain JavaScript object mutated from everywhere. A gameState property on a global window object. Flags set with gameState.flags.whatever = true. Resources modified with gameState.endurance += 10. Every UI component reads and writes to the same object. The first time something goes wrong, you spend four hours chasing a race condition between the save system and the choice renderer. The second time, you lose a player's progression. The third time, you delete the project.

NICHLYST has 40 days, 33 endings, 130 boolean flags, and six moral axes (truth, mercy, preservation, complicity, memory, survival). The game data alone weighs 230 kilobytes of JSON. The exponential explosion of possible states is not theoretical. It is the daily engineering reality.

The solution was not cleverer writing. It was a strict, ruthless state machine.


The Eight Waves of Canon

Before describing the machine, it helps to understand what the machine protects. The 40 days are not a flat sequence. They are partitioned into eight thematic waves, each with its own mechanical identity and narrative contract.

Wave 1: The Cold Awakening (Days 1-3). The bunker. The first visitor. The player learns to listen, to record, to choose between silence and speech. Mechanical introduction: resource gauges, flag system, the weight of early decisions.

Wave 2: The First Ruptures (Days 4-7). The network stirs. Taras brings diesel. Khoma brings bread. Solomiia brings chalk. Each visitor tests the player's instinct: protect the archive or protect the person. Trust begins to fracture.

Wave 3: The Tightening Net (Days 8-12). Surveillance escalates. The Directorate tightens its grip. Choices become binary: comply or resist, at increasing cost. The player's endurance drains faster. Hope flickers.

Wave 4: The Great Raid (Days 13-15). The climax of the primary arc. The raid, the evacuation, the silence. The player faces the transmission choice: broadcast the archive into the open air, or seal it in an iron crypt. The 33 endings branch from this threshold.

Wave 5: The Silence of Ash (Days 16-20). The postscript begins. The world above has stopped. The player sorts through burnt paper, frozen salt, severed telephone wires. The choices shrink to single sentences. Frost on iron. Chalk dust. An empty chair.

Wave 6: The Postscript Residue (Days 21-23). Phantom flora. Paper mice. Documents that curl and mutate in the damp. The archive begins to develop a life of its own, independent of the archivist's intent.

Wave 7: The Hallucinatory Drift (Days 25-33). The hidden postscript. Reality bends. Memory echoes surface. The player navigates a space between remembering and forgetting, where the moral ledger exerts its final pressure.

Wave 8: The Afterlife Reckoning (Days 34-40). The archive afterlife. What survives. What decomposes. What the world remembers, and what it does not. The final endings: transmission success, transmission failure, burial preserved, burial lost, mercy destruction, haunted destruction, echo, ashes.

Each wave has a distinct emotional register and mechanical behavior. The state machine does not care about themes. It cares about invariants. The eight waves exist so that the machine always knows which rules apply.


The Iron Container: Engineering the State Machine

The state manager lives in www/js/state_manager.js. It is 777 lines of JavaScript that enforce a single principle: no external code may mutate game state directly.

Here is the core of it.

class StateManager {
  #gameData;
  #resources = {};
  #flags = {};
  #archive = {};
  #currentDayId = 1;
  #gamePhase = 'main';
  #moralLedger = { truth: 0, mercy: 0, preservation: 0,
                   complicity: 0, memory: 0, survival: 0 };

  get resources() { return Object.freeze({ ...this.#resources }); }
  get flags() { return Object.freeze({ ...this.#flags }); }
  get moralLedger() { return Object.freeze({ ...this.#moralLedger }); }

  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();
  }

  getStateSnapshot() {
    return {
      resources: this.resources,
      flags: this.flags,
      archive: this.archive,
      currentDayId: this.#currentDayId,
      moralLedger: this.moralLedger
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Every getter returns a frozen copy. Every mutation goes through modifyResource or applyDeltas, both of which clamp values between config.min and config.max (0 and 100 for most resources). The #private fields mean that if some UI component tries state.resources.endurance = 999, it simply fails. The property does not exist on the frozen copy. The internal #resources object is unreachable from outside the class.

This is not fancy engineering. It is defensive engineering. The distinction matters when you are building on a laptop that freezes if you open too many Chrome tabs.

The applyChoice method is the central pipeline. A choice arrives. Base deltas are applied and clamped. Conditional effects fire if the relevant flag is set. Flags are written. The moral ledger updates. Fatigue accumulates. Attention restores. Testimony corruption tracks. Weather effects apply. Surveillance stress penalizes endurance above 70. Codex fragments unlock. Memory echoes surface. Delayed notifications schedule. Screen readers announce the change log. At the end of the pipeline, getStateSnapshot() hands the UI a frozen, immutable snapshot. The UI never touches the internal state. It receives a photograph, not a live feed.


Antifragility in Practice

The rule of graceful degradation was simple: if a system is missing, skip it. If a flag is undefined, treat it as false. If a resource key does not exist in the schema, ignore the delta silently. If the save file is corrupted, clamp resources to valid bounds and set #tampered = true rather than crashing.

modifyResource(key, delta) {
  const config = this.#gameData.global_resources[key];
  if (!config) return;           // Unknown key: silent skip
  const current = this.#resources[key] ?? config.start;
  this.#resources[key] = Math.max(config.min,
    Math.min(config.max, current + Number(delta)));
  this.autoSave();
}
Enter fullscreen mode Exit fullscreen mode

The restoreFromData method validates every field on load. Missing fields get defaults. Out-of-range values get clamped. The validateResources helper returns a corrected object alongside a valid boolean. If the save was tampered with (detected via HMAC-SHA256 signature in SaveManager), the system logs a warning to Sentry, sets the tamper flag, and continues. The player never sees the corruption. The game continues to function. This is antifragility: the system does not merely survive disorder, it absorbs disorder and carries on.


The Final Countdown

September 24 approaches. The 14-day closed testing window ends. Nine days remain until the final Devpost submission. The state machine holds. The 40 days compile into a single 230-kilobyte JSON bundle. The 33 endings remain intact. The 130 flags initialize cleanly on every fresh session.

There is a peculiar discipline in building something that must not change. The urge to add one more feature, one more flag, one more ending is constant. The correct response is always the same: no. The architecture is the architecture. The eight waves are the eight waves. The iron container holds what it was built to hold.

Outside, the sirens will sound again. The AMD A4 will spin its fan. The terminal will display its output. The state machine will clamp its values between 0 and 100, freeze its snapshots, and hand them to a UI that never touches the source. This is how a solo builder in wartime Kyiv keeps forty days of branching narrative from collapsing into chaos.

One wave at a time. One invariant at a time. One frozen snapshot at a time.

Top comments (0)