When I set out to build Emberfall, one constraint shaped everything else: it had to run instantly in a browser tab, with no install and no login. That decision reaches all the way down into the code. There is no bundler, no module system, and no back-end. The whole game is a single emberfall.html shell that pulls in a long, ordered list of plain <script> tags — roughly fifty of them — and every one hangs its functions and data on the global scope. It sounds primitive, but in practice it loads fast, is trivial to debug in DevTools, and is completely self-contained. This devlog walks through how the pieces fit together.
The module layout
The load order in the HTML is the architecture. Because there are no imports, a file can only call something that a file above it already defined, so the order encodes the dependency graph. Content comes first, then state, then systems, then the rendering and engine layer, then optional features, and finally the game loop that drives everything.
- data.js — the content tables. This is the biggest data file in the game and it holds
CLASS_DATA,ENEMY_DATA,ITEM_DATA,SKILL_DATA, andPROMOTION_DATA. Nothing here is logic; it is the raw description of the world in plain objects. - state.js — the single source of truth. It declares the two globals the whole game revolves around,
G(the persistent game state) andB(temporary battle state), and owns save/load, character creation, inventory, equipment, and leveling. - combat.js — the largest file in the project. It runs the turn loop, the damage math, the status-effect system, and the enemy AI.
- ui.js — the screen renderer. Emberfall is built as a set of full-screen "screens" toggled by a tiny
showScreen(id)helper, and this file paints them. - The engine folder — a small canvas engine under
js/engine/: aSceneManagerscene stack (scene.js), the exploration world, the animatedbattle_arena.js, sprite sheets, dialogue, particles, camera, and the interaction system. - Feature modules — self-contained systems layered on top: the shop, the seasonal pass, daily challenges, a weekly world boss, raids, factions, trials, a codex, and build loadouts, among others.
- world.js — loaded last, because it is the driver. It owns the requestAnimationFrame loop and the
SceneManagerthat stacks scenes: a base world scene with a dialogue scene pushed on top when you talk to someone.
data.js never contains behavior and state.js never contains rendering. Combat mutates G and B; the UI only reads them and repaints. Keeping that one-way flow is what stops a frameworkless codebase from turning into spaghetti.How turn-based combat is modeled
Every fight lives in the temporary B object, which is spun up when a battle starts and thrown away when it ends — it is deliberately never saved. On your turn you pick one of three actions: Attack, a Skill, or an Item (you can also attempt to flee). Skills carry a cooldown and a resource cost, and the resource depends on your class: mages and clerics spend MP, rogues spend energy, and warriors pay nothing because their kit is built around raw attacks and stances.
Physical damage runs through one function, calcPhysicalDamage. The core line is base = max(1, atk − enemyDef × 0.4 + variance), where variance is a small random swing between −2 and +2 so no two hits are identical. Critical hits are a flat chance that starts at 5% for most classes but is 25% for the Rogue, then nudged up by your luck stat; a crit doubles the damage. Magic damage is its own formula — (atk + int × 0.5) × skillMultiplier − enemyDef × 0.2 — which is why Intelligence scaling makes a Mage's spells hit so much harder than a physical swing of the same base power.
Incoming damage is where the design really shows. Everything that might save you from a hit is funneled through a single function, dealDamageToPlayer, as an ordered series of gates: a parry check, artifact wards, curse amplifiers that increase the damage, class dodges, shields, fortify reduction, and finally a vulnerability multiplier. Because it is one linear pipeline, the order of operations is completely predictable — I always know exactly how two defensive effects stack, and so can a player who pays attention.
The four classes, from the data table
Each class is defined by its base stats and a single always-on passive, straight out of CLASS_DATA. These are the real starting numbers:
| Class | Base HP | Base ATK | Resource | Passive |
|---|---|---|---|---|
| ⚔️ Warrior | 120 | 12 | None | ~20% chance to halve an incoming hit |
| 🗡️ Rogue | 90 | 15 | Energy (100) | 25% base critical-hit chance |
| 🔮 Mage | 75 | 10 | MP (80) | Intelligence scaling on spells |
| ✨ Cleric | 105 | 11 | Faith / MP (80) | 25% chance to heal 5% max HP after surviving a hit |
You can read each class's identity straight out of those rows. The Warrior has the highest HP and a coin-flip damage reduction, so it is the durable pick; the Rogue trades HP for the highest base attack and a crit rate five times the default; the Mage is a glass cannon whose low HP is paired with the highest Intelligence its damage formula rewards; and the Cleric sits in the middle, winning long fights through its survival-triggered heal. None of that is written in prose anywhere in the code — it emerges from the numbers.
Status effects are just plain data
I wanted status effects to be easy to add and easy to reason about, so they are nothing more than small objects — { type, turns, value } — pushed into two arrays, B.enemyStatus and B.playerStatus. Applying one is a two-line function: if the effect is already present, refresh its duration and value; otherwise push a new entry. A single tickStatusEffects function runs once per round, walks both lists, resolves each effect, decrements its turns, and filters out anything that hit zero.
The resolution logic is where each effect earns its personality. Poison, burn, and bleed deal damage-over-time from their stored value; fortify and a defense-nullify effect restore the enemy's real defense when they expire. A couple deliberately break the rules: a Golem Shield is never ticked down, because it is meant to be consumed by the next attack rather than by time. Each status also maps to an emoji in a small icon table, so the UI can paint the row without knowing anything about combat.
| Effect | Icon | What it does |
|---|---|---|
| Poison | 💚 | Damage each turn; a Rogue talent boosts the tick |
| Burn | 🔥 | Fire damage over time |
| Slow | ❄️ | Weakens the afflicted attacker |
| Fortify | 🗿 | Reduces incoming damage while active |
| Stun | ⭐ | Skips the target's turn |
| Vulnerable | 🩸 | Amplifies the next hit taken |
Emberfall runs free in your browser — no download, no login, and your progress saves automatically.
Readable enemies: patterns, not randomness
I did not want combat to feel like rolling dice against the AI, so ordinary enemies do not choose moves randomly at all. Every entry in ENEMY_DATA ships with a fixed pattern array — an Ash Rat is simply ['attack','attack','attack'], while a Stone Golem cycles ['charge','slam','charge','slam']. Combat steps through that array with an index that wraps around modulo its length, so the rhythm is learnable. Once you have seen a foe's loop, you can read its next move and plan around it.
Bosses break that predictability on purpose. Instead of one static pattern, a boss swaps to a different, more aggressive pattern array as its HP crosses set thresholds — the Stone Sentinel, for instance, shifts tactics at 120 and again at 60 HP. Each shift is telegraphed: the enemy card flashes a warning and the log announces that the boss "awakens" or enters its "final stand." That warning is the whole point of the phase system — it hands you a turn to react before the burst lands.
Progression and the level curve
Leveling is a compact bit of math in state.js. The XP required for the next level is round(120 × level^1.6) — a curve that climbs smoothly rather than in cliffs — and the level cap is 35. Every level-up runs recalcStats, which rebuilds your stats from class base values, per-level growth, and equipment, then fully restores your HP so you enter the next fight healed. Once you hit the cap, leftover XP is not wasted: it spills into a separate Paragon track. Around level 30, after a specific late-game boss is beaten, each base class can promote into one of two elite specializations, doubling the number of distinct builds.
How saves actually work
There is no account and no cloud, so persistence is entirely local. Saving is one line at its heart: localStorage.setItem(key, JSON.stringify(G)). The entire game state is that one G object — player, inventory, equipment, quests, stats, unlocked bosses, cosmetics, the seasonal pass, companions, factions, and more — while the transient B battle object is intentionally excluded. Emberfall supports three save slots, keyed ef_save_0, ef_save_1, and ef_save_2, and an older single-key save is quietly migrated into slot 0 the first time it is seen. Each save also records a friendly date and running playtime so the slot picker can show them.
The part I am most careful about is migration. G carries a version number, and loadGame walks a chain of if (version < N) blocks that only ever add missing fields with sensible defaults — a new stat counter here, a new sub-object there — before bumping the version forward. On top of that there is a belt-and-suspenders layer of if (!G.something) G.something = ... guards for features that were added without a version bump. The effect is that a save written by a much older build of the game still loads cleanly on the current one instead of crashing on a missing property. For a game that lives in a browser tab, where I cannot ask anyone to "patch," non-destructive migration is not a nicety — it is the thing that keeps old players' runs alive.
Why build it this way
Read the source top to bottom and the recurring theme is deliberate predictability. Enemies follow patterns you can learn. Damage flows through single functions with a fixed order of operations. Status effects are plain data processed by one loop. Saves grow by addition, never by deletion. None of these are the flashiest choices, but together they make a large game tractable to build with no framework underneath it — and they make combat feel fair, because almost everything that happens to you is something you could have seen coming.