App Survival: Android Release Night is a web-based simulation game that models Android production trade-offs. Players place architecture components, link dependencies, and respond to incidents to keep a simulated app alive under realistic pressure.
The project is built with TypeScript and Vite. There are zero runtime dependencies -- only devDependencies for the build toolchain (Vite, TypeScript, Vitest, Playwright). All game logic, rendering, and i18n run in a single browser bundle.
The core loop follows a unidirectional pattern:
GameSim.tick() --> sim mutates internal state
|
v
main.ts calls sim.getUIState() --> produces a plain UIState snapshot
|
v
syncUI() reads UIState --> patches DOM elements directly
There is no framework and no virtual DOM. syncUI() in main.ts reads the snapshot and sets textContent, classList, disabled, etc. on cached element references.
The simulation runs at one tick per second (TICK_MS = 1000). On each tick:
tickPlatformPulse()andtickZeroDayPulse()apply slow-moving world pressure.tickCoverageGate()checks test coverage drift.maybeIncident()rolls for random incidents (at most one every 26 seconds).- Per-component stats are recomputed and requests are spawned and routed.
- Metrics (failure rate, ANR risk, latency, jank, heap, GC) are updated.
- Score, rating, and budget are adjusted.
The tick loop is driven by window.setInterval in startTickLoop(). After each tick, requestUISync() schedules a requestAnimationFrame (or setTimeout in E2E mode) that calls syncUI().
| File | Purpose |
|---|---|
src/sim.ts |
Simulation engine. Contains GameSim, component defs, action defs, incident logic, tick loop internals, scoring, tickets, architecture rules. |
src/subsystems.ts |
Pure-function subsystems (tickPlatformPulse, tickCoverageGate, computeRegionTarget). Each takes a read-only input and returns a mutation descriptor; GameSim applies it. This is the portable boundary for the stated Kotlin/KMP port goal. |
src/types.ts |
Shared type definitions. COMPONENT_TYPES, ACTION_KEYS, ComponentDef, Component, UIState, Ticket, RunResult, etc. |
src/main.ts |
Entry point. DOM binding, event handlers, canvas rendering, the syncUI() function, theme/tab/language setup, integrity checks. |
src/achievements.ts |
Achievement catalog and tracker. Defines tiers (bronze/silver/gold), evaluation per preset, storage adapters. |
src/scoreboard.ts |
Local scoreboard persistence (localStorage). Load, save, add, clear, seal, verify. |
src/rng.ts |
Deterministic PRNG (mulberry32). Seeded per run for reproducibility. |
src/entropy.ts |
Passive entropy collector. Gathers mouse/keyboard/pointer timing to generate high-quality seeds. |
src/integrity.ts |
Tamper detection. HMAC signing of localStorage data, score sanity checks, migration logic. |
src/i18n.ts |
Internationalization. All UI strings, fallback chains, t() lookup function, DOM data-i18n attribute patching. |
-
Add the type name to
COMPONENT_TYPESinsrc/types.ts. This is aconsttuple. Append your new type (e.g.'BILLING') to the array. TheComponentTypeunion is derived automatically.export const COMPONENT_TYPES = [ 'UI','VM','DOMAIN','REPO','CACHE','DB','NET','WORK','OBS','FLAGS', 'AUTH','PINNING','KEYSTORE','SANITIZER','ABUSE','A11Y', 'BILLING' // <-- new ] as const;
-
Add a
ComponentDefentry insrc/sim.ts. Add to theComponentDefsrecord. SetbaseCap(capacity),baseLat(latency ms),baseFail(failure rate 0..1),cost(budget to place),upgrade(costs per tier:[0, tier2, tier3, 0]), anddesc(short description for the UI).BILLING: { baseCap: 6, baseLat: 20, baseFail: 0.0030, cost: 90, upgrade: [0, 120, 180, 0], desc: 'Billing (IAP/subscription lifecycle)' },
-
Add a
COMPONENT_DEPSentry insrc/sim.ts. List the third-party dependency tags this component exposes to zero-day advisory pressure. Valid tags:'net','image','json','auth','analytics'. Use an empty array if none apply.BILLING: ['net', 'json'],
-
Decide the layer placement. The architecture rules enforce a layered model (UI > VM > DOMAIN > REPO > data layers). Sidecars (OBS, FLAGS, A11Y) can be depended on from anywhere. If your component is a sidecar, no extra work is needed. If it belongs in the main pipeline, review the layer ordering logic in
sim.tsto ensure it fits. -
Test. Place the component in-game and verify it receives requests, upgrades correctly, and interacts with incidents as expected.
-
Add the kind to the
IncidentKindunion insrc/sim.ts.type IncidentKind = | 'TRAFFIC_SPIKE' // ... existing kinds ... | 'MY_NEW_INCIDENT';
-
Add a weighted entry to the
tablearray insidemaybeIncident(). Weights should sum to approximately 1.0 across all entries. Adjust existing weights down to make room.const table: Array<[IncidentKind, number]> = [ ['TRAFFIC_SPIKE', 0.17], // ... existing entries with slightly reduced weights ... ['MY_NEW_INCIDENT', 0.05], ];
-
Add a
casein theswitch (kind)block. Use the existing helpers:bumpSupport(n)to increase support load,hitTrust(privacy, security, a11y)to adjust perception metrics. Read component tiers withthis.tierOf('TYPE')to implement mitigation (higher tiers should reduce impact). Log the incident withthis.log('message').case 'MY_NEW_INCIDENT': { const someTier = this.tierOf('NET'); const damp = someTier > 0 ? 0.7 : 1.0; this.netBadness = clamp(this.netBadness + 0.20 * damp, 1.0, 3.0); bumpSupport(4); this.log('Something happened: network degraded.'); break; }
-
Balance. Incidents should be punishing but recoverable. Test with different component layouts and presets. Check that a mitigation path exists (placing/upgrading a specific component should reduce the impact).
Subsystems are pure functions that model a slice of world state (platform drift, coverage decay, regional policy targets). They live in src/subsystems.ts, read a snapshot input, and return a result descriptor. GameSim holds all mutable state and applies the result. This separation is what makes the sim portable to Kotlin/KMP.
-
Define input and result types in
src/subsystems.ts. Mirror the shape ofCoverageGateInput/CoverageGateResult: inputs are read-only snapshots; results describe mutations or events the orchestrator should apply.export type MySubsystemInput = { foo: number; timeSec: number }; export type MySubsystemResult = { foo: number; shouldEmitTicket: boolean };
-
Write a pure function. No
this, no side effects, noMath.random. Accept arandFn: () => numberparameter if you need randomness (seetickPlatformPulse).export function tickMySubsystem(input: MySubsystemInput, randFn: () => number): MySubsystemResult { // derive new values; never mutate input return { foo: input.foo - 1, shouldEmitTicket: input.foo <= 0 }; }
-
Wire it into
GameSimwith a thin private method that builds the input, calls the pure function, and writes results back. MirrortickCoverageGateinsrc/sim.ts(around lines 1176-1201) as the reference. -
Unit-test the pure function directly. No
GameSimneeded, no mocks, just input/output assertions.
Subsystem extraction is the long-term direction for the KMP port. See ARCHITECTURE_RULES.md for the layered model the game itself models.
Scenarios are scripted shifts defined as pure data in src/scenarios.ts. They
pin a seed, preset, and an ordered list of incident markers; the sim's
maybeIncident detects scripted ticks and fires the marker directly without
consuming rand() for selection, so the same scenario always plays the same
timeline.
- Pick a seed. Use a deterministic constant (e.g.
0xCAD0000 | 0) rather than a "random-looking" literal. The seed controls everything outside the incident script — background pressure, region targets, review waves. - Write the incident script. Each
{ atSec, kind }marker fires at that exact tick. Space markers at least 30–60 seconds apart so the player has time to react. - Pick a goal type from
ScenarioGoal(RATING,COMPLIANCE,ZERO_DEBT,NO_CRASH_TICKETS). Bonus multipliers should reflect difficulty — 1.35× for Senior, 1.40× for Staff, 1.45× for Principal. - Add the entry to
SCENARIOS, update SCENARIOS.md, and add a determinism test (two runs at the same scenario must produce identical event streams — seetests/unit/scenarios.test.ts).
-
Add an entry to the array returned by
achievementCatalog()insrc/achievements.ts.{ id: 'JM_MY_ACH', // unique ID, prefixed by preset bucket bucket: EVAL_PRESET.JUNIOR_MID, // which preset this belongs to visibility: 'PUBLIC', // or 'HIDDEN' (revealed on unlock) title: 'My Achievement', tiers: [ { tier: 1, label: 'BRONZE', description: 'Do X once.', reward: { budget: 100 } }, { tier: 2, label: 'SILVER', description: 'Do X 3 times.', reward: { budget: 160 } }, { tier: 3, label: 'GOLD', description: 'Do X 5 times.', reward: { score: 300 } }, ], },
-
Add evaluation logic in
AchievementsTracker. The tracker receivesAchEventobjects (RUN_START, RUN_END, TICK, INCIDENT, TICKET_FIXED, PURCHASE). In the evaluation method for the relevant preset, check your conditions against the tracker's accumulated state and return the highest tier met. -
Choose rewards carefully. Budget rewards help early-game recovery. Score rewards help leaderboard positioning. Keep rewards modest to avoid a "must-grind" meta. See existing achievements for reference values.
-
Test. Use the
InMemoryAchStorageadapter in unit tests. Feed synthetic events and verify the correct tier unlocks. Seetests/unit/achievements.test.tsfor patterns.
All UI strings live in src/i18n.ts inside the DICTS object, keyed by language code.
-
Add keys to the
endictionary first. English is the source of truth and the final fallback.const DICTS: Partial<Record<Lang, Dict>> = { en: { // ... existing keys ... 'card.myFeature.title': 'My Feature', 'card.myFeature.desc': 'Description with {variable} interpolation.', }, // ... };
-
Reference keys in HTML or code.
- In HTML:
<span data-i18n="card.myFeature.title"></span>,<input data-i18n-placeholder="...">, or<button data-i18n-title="...">. - In code:
t('card.myFeature.desc', { variable: someValue }).
- In HTML:
-
Add translations to other languages. Add the same key to
es,fr,de, etc. insideDICTS. You do not need to translate into every language; the fallback chain handles missing keys automatically:- Regional variants (e.g.
fr-CH) fall back to their base language (fr). - All languages ultimately fall back to
en.
- Regional variants (e.g.
-
Adding a new language. Add the language code to the
Langtype union, add aLocaleMetaentry to theLOCALESarray (withgroupand optionalbase/betafields), and add a dict entry inDICTS. -
Dev-mode warnings. In dev mode, any key lookup that falls through to the raw key string logs a console warning (
[i18n] missing key). This helps catch untranslated keys early.
The project uses Vite with three environment variables:
| Variable | Purpose | Default |
|---|---|---|
VITE_BASE |
Sets base in vite.config.ts. For GitHub Pages under a repo path, CI sets this to /<repo>/. For custom domains or local dev, use /. |
/ |
VITE_COMMIT_SHA |
The git commit SHA injected at build time. Used by integrity checks to derive an HMAC key for tamper detection of localStorage data. Shown in the build info footer. | 'dev' |
VITE_E2E |
When set to '1', the app enters E2E mode: sets window.__E2E__ = true, adds a e2e CSS class (disables animations), and forces the seed to 12345 for determinism. |
unset |
Local development:
npm install
npm run dev # Vite dev server on port 5173
npm run build # TypeScript check + Vite production build
npm run preview # Serve the production build locallyUnit tests live in tests/unit/ and run with Vitest:
npm run test:unit # single run
npm run test:watch # watch modeWriting unit tests:
Instantiate GameSim directly and manipulate state. The sim has no DOM dependency.
import { describe, it, expect } from 'vitest';
import { GameSim } from '../../src/sim';
import { EVAL_PRESET } from '../../src/types';
describe('MyFeature', () => {
it('does something', () => {
const sim = new GameSim();
sim.setPreset(EVAL_PRESET.SENIOR);
// Manipulate internal state (cast to any for private fields if needed).
(sim as any).coveragePct = 60;
// Call internal methods directly.
(sim as any).tickCoverageGate();
// Assert against public getters.
const tickets = sim.getTickets();
expect(tickets.some(t => t.kind === 'TEST_COVERAGE')).toBe(true);
});
});For achievement tests, use InMemoryAchStorage instead of localStorage:
import { AchievementsTracker, InMemoryAchStorage } from '../../src/achievements';
const store = new InMemoryAchStorage();
const tracker = new AchievementsTracker(EVAL_PRESET.JUNIOR_MID, store);
const unlocked = tracker.onEvents([
{ type: 'RUN_START', atSec: 0, budget: 3000, architectureDebt: 0 },
{ type: 'TICKET_FIXED', atSec: 10, kind: 'BUG' as any, effort: 4 },
]);
expect(unlocked.map(a => `${a.id}:${a.tier}`)).toContain('JM_SHIP_IT:1');A static script checks that all id and data-* attributes referenced in main.ts exist in index.html:
npm run test:domRun this after modifying HTML element IDs or adding new data-i18n hooks.
E2E tests live in tests/e2e/ and use Playwright:
npm run test:e2e # run against a local preview build
npm run test:e2e:ci # DOM validation + Playwright (used in CI)Playwright builds the app with VITE_BASE=/ VITE_E2E=1, then serves it on port 4173. The VITE_E2E=1 flag ensures:
- The app uses a fixed seed (
12345) for deterministic simulation. - CSS animations are disabled to reduce flake.
requestAnimationFrameis replaced withsetTimeout(fn, 0)for reliable DOM updates in headless mode.
Writing E2E tests:
import { test, expect } from '@playwright/test';
test('my feature works', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#someElement')).toBeVisible();
await page.click('#btnStart');
await page.waitForTimeout(2200); // wait for 2+ ticks
// Assert DOM state changed.
});npm test # runs test:unit, then build (type-check + bundle)