DEV Community

Cover image for I Built a Full RPG with Zero JavaScript Logic - Here's What I Learned
iDev-Games
iDev-Games

Posted on

I Built a Full RPG with Zero JavaScript Logic - Here's What I Learned

A deep dive into State.js by building Gutter Rat: a working SPA game with combat, shops, inventory, and level-ups — all in HTML and CSS.


Here's the CodePen. The JS panel is empty.

Take a minute to play it. Buy gear, pick a fight, grind XP, level up. Then come back and I'll explain exactly how it works and what building it taught me.


What is State.js?

State.js is a reactive state library that exposes application state as HTML data attributes and CSS custom properties. Instead of JavaScript managing the DOM, the DOM is the state — and CSS subscribes to it directly.

It's part of a broader browser-native stack built by @iDev_Games:

  • Trig.js — scroll position to CSS
  • Cursor.js — mouse/touch position to CSS
  • Keys.js — keyboard input to CSS
  • Motion.js — global animation clock for CSS
  • Gravity.js — DOM physics engine rendered in CSS
  • State.js — reactive application state layer

Each one independently useful. Together they form a complete declarative application engine that runs directly in the browser with no build step, no bundler, and no framework.

The philosophy: your HTML is your state graph, CSS is your rendering engine, and JavaScript is only for what JavaScript is genuinely good at.


The Project: Gutter Rat

I wanted to build a demo that pushed State.js hard — not a counter, not a toggle, but something with real game logic: an SPA with multiple panels, an economy, combat with health bars, a shop that modifies stats, dynamic inventory, conditional UI, and automatic level detection.

The result is Gutter Rat — a grimy street RPG in the spirit of old browser games like Hobo Wars. Scraps McBurly. The Rusty Alley. Big Vince. You know the vibe.

What it has:

  • 4-panel SPA (Town, Fight, Gear, Stats) with animated transitions
  • HP/MP/XP bars driven by CSS custom properties
  • Gold economy with healing, drinks, scavenging, and gambling
  • Combat system with punch, haymaker, autofire victory/defeat detection
  • Shop that dynamically spawns items into an inventory grid
  • Attribute training (ATK, DEF, SPD)
  • Level-up toast that fires automatically when XP maxes out
  • Win/loss tracking

Zero lines of JavaScript logic. One <script> tag pointing at State.js.


How the SPA Routing Works

This is the most immediately useful pattern for anyone building with State.js.

The entire panel-switching system runs on a single numeric attribute:

<div id="app"
     data-state
     data-state-watch="panel"
     data-panel="1" data-panel-min="1" data-panel-max="4">
Enter fullscreen mode Exit fullscreen mode

Nav buttons set it directly:

<button data-state data-state-trigger data-state-bind="app"
        data-state-attr="panel" data-state-value="1">
  🏚️ Town
</button>
Enter fullscreen mode Exit fullscreen mode

CSS does the routing:

.panel { display: none; }
#app[data-panel="1"] .panel-town  { display: block; }
#app[data-panel="2"] .panel-fight { display: block; }
Enter fullscreen mode Exit fullscreen mode

Active nav highlighting follows for free:

#app[data-panel="1"] .nav-btn[data-nav="1"] {
  color: var(--amber);
  border-bottom: 2px solid var(--amber);
}
Enter fullscreen mode Exit fullscreen mode

Why this works better than a toggle-per-panel approach: data-state-attr + data-state-value sets a value directly — idempotent, no flipping. Clicking Town three times in a row stays on Town. Whereas data-state-toggle flips a boolean, which means clicking the same panel twice would close it. For routing, you want assignment, not toggle.


The HUD: CSS Variables as a Render Layer

Once you add an attribute to data-state-watch, State.js exposes it as a CSS custom property automatically. HP becomes --state-hp-percent. That's it.

<div class="bar-fill hp"></div>
Enter fullscreen mode Exit fullscreen mode
.bar-fill.hp {
  width: var(--state-hp-percent);
  background: linear-gradient(90deg, #6b1010, #c84040);
  transition: width 0.3s ease;
}
Enter fullscreen mode Exit fullscreen mode

The bar animates on every HP change with zero JavaScript. The browser's CSS engine handles the interpolation. This is the pattern that makes State.js genuinely fast — you're not triggering layout recalculations through JS, you're updating a CSS variable and letting the compositor handle the rest.


Trigger Chains: Your Game Logic Layer

This is where State.js gets powerful. A single button press can fire multiple sequential state changes via data-state-trigger-chain.

Buying a knife:

<button data-state data-state-trigger data-state-bind="app"
        data-state-instantiate="item-tpl"
        data-state-target="#inv-grid"
        data-state-set-icon="🔪"
        data-state-set-label="Knife"
        data-state-set-bonus="+3 ATK"
        data-state-condition="gold >= 15"
        data-state-trigger-chain="buyKnife,addKnifeAtk">
  Buy
</button>
Enter fullscreen mode Exit fullscreen mode
<!-- Hidden trigger chain buttons -->
<button id="buyKnife"   ...  data-state-attr="gold" data-state-decrement="15"></button>
<button id="addKnifeAtk" ... data-state-attr="atk"  data-state-increment="3"></button>
Enter fullscreen mode Exit fullscreen mode

One click: spawns an inventory card, deducts 15 gold, adds 3 ATK. Three side effects from pure HTML.

Important note about chains: each chain link fires independently. If a link has a data-state-condition that fails, that link is skipped — but the rest of the chain still fires. Design your chains with this in mind. Don't put required dependencies after conditional steps.


Dynamic Inventory with data-state-instantiate

This was the most satisfying feature to get working. The Gear panel starts empty:

<div id="inv-grid">
  <div class="inv-empty">No gear yet. Hit the Town market.</div>
</div>
Enter fullscreen mode Exit fullscreen mode

There's a hidden template:

<div id="item-tpl" class="inv-slot" style="display:none"
     data-state data-state-watch="icon,label,bonus"
     data-icon="🎁" data-label="Item" data-bonus="">
  <span data-state-display="icon">🎁</span>
  <span class="slot-label" data-state-display="label">Item</span>
  <span class="slot-bonus" data-state-display="bonus"></span>
</div>
Enter fullscreen mode Exit fullscreen mode

Each shop button uses data-state-instantiate with data-state-set-* overrides:

<button data-state-instantiate="item-tpl"
        data-state-target="#inv-grid"
        data-state-set-icon="🔪"
        data-state-set-label="Knife"
        data-state-set-bonus="+3 ATK">
Enter fullscreen mode Exit fullscreen mode

State.js clones the template, applies the overrides as data-* attributes on the clone, auto-initialises State.js on the clone, strips display:none, and appends it to #inv-grid. The data-state-display elements inside each clone read from their own local scope, not the parent — so every spawned item correctly shows its own icon and stats.


Autofire: Reactive Logic Without Writing Logic

data-state-autofire="true" fires a trigger automatically the moment its condition flips from false to true. This is the closest thing State.js has to a computed reactive value, and it's how the level-up detection works:

<button id="autoLevelUp"
        data-state data-state-trigger data-state-bind="app"
        data-state-toggle="levelUp"
        data-state-condition="xp >= 100 and levelUp == false"
        data-state-autofire="true"
        data-state-trigger-chain="doLevelUp"
        style="display:none">
</button>
Enter fullscreen mode Exit fullscreen mode

The moment XP hits 100, this fires — no click needed, no JS event listener, no polling. The level-up toast appears and the level increments. Then the "Keep Brawling" button resets XP to 0 and toggles levelUp back off.

Critical gotcha with autofire: if the condition is already true when the page loads, it fires immediately on init. In Gutter Rat, enemyHp starts at 0, which meant enemyHp <= 0 was true on load and the victory autofire was triggering before any fight began. The fix was a fightActive toggle that starts false and only flips true when you actually pick an enemy — gating the autofire with fightActive == true and enemyHp <= 0.


The Gotcha That Will Burn Everyone Once

Data attribute names must be lowercase in CSS selectors and State.js class names.

This is HTML's rule, not State.js's. The DOM normalises all attribute names to lowercase. But the consequence in State.js is specific: data-state-toggles="choosingEnemy" generates the CSS class state-choosingenemy — fully lowercase — not state-choosingEnemy.

If your CSS says:

#app.state-choosingEnemy .enemy-select { display: block; }
Enter fullscreen mode Exit fullscreen mode

It will never match. The correct selector is:

#app.state-choosingenemy .enemy-select { display: block; }
Enter fullscreen mode Exit fullscreen mode

This applies to every camelCase toggle name. levelUp becomes state-levelup. fightActive becomes state-fightactive. Write your CSS selectors in lowercase and you'll never hit this.

The same rule applies to data-state-watch attributes — keep them lowercase with hyphens. data-enemyHp will work for attribute reads, but the CSS variable will be --state-enemyhp-percent. Stick to lowercase throughout and it's consistent.


Setting vs Toggling Booleans

This one is subtle but important. data-state-toggle="victory" flips the boolean. If victory is false, it becomes true. If you call it again, it goes back to false.

That's the wrong tool when you need to set a specific value — like resetting combat state when starting a new fight. The right tool is data-state-attr + data-state-value:

<button id="resetVictory"
        data-state data-state-trigger data-state-bind="app"
        data-state-attr="victory"
        data-state-value="false"
        style="display:none">
</button>
Enter fullscreen mode Exit fullscreen mode

Even though victory is in data-state-toggles, you can write its underlying attribute directly. State.js picks up the change through its MutationObserver and updates the CSS class accordingly. This gives you idempotent state assignment instead of a blind flip — essential for reset operations in game loops.


Debugging: Use the Tools

State.js has a console debug API that would have caught the lowercase CSS issue in seconds:

// See all reactive state at once
State.inspectAll()

// Check specific element
State.inspect('#app')

// Watch attribute changes in real time
State.trace('choosingEnemy', true)
// Logs every change to data-choosingEnemy as it happens
Enter fullscreen mode Exit fullscreen mode

When something isn't working, State.trace('attributeName', true) is the first thing to run. It tells you immediately whether the attribute is changing (State.js working) vs. the CSS not responding (selector issue) vs. the condition not evaluating (logic issue). Those are three completely different problems with completely different fixes.


The Honest Assessment

State.js draws a clean line at the boundary of declarative and algorithmic. Everything reactive — state changes, conditional UI, event-driven side effects, dynamic DOM.

The hybrid approach is the optimal position for most projects: State.js handles the reactive layer, and you write the twenty lines of JS you actually need for the algorithm. You're not replacing JavaScript. You're eliminating the unnecessary JavaScript.


Why This Matters

React solves "how do I make the UI reflect state" by pulling everything into JavaScript and rendering down to the DOM. State.js solves the same problem by treating the DOM as the state store and CSS as the reactive subscriber.

React's approach is powerful but it requires buying into an entire mental model, a build pipeline, JSX, hooks, a component lifecycle. The payload for "show this element when this condition is true" is enormous.

With State.js the payload is one attribute. The CSS you already wrote does the rest.

There's also a real and underserved audience here: designers, WordPress developers, people who are genuinely good at HTML and CSS but hit a wall the moment interactivity requires JavaScript. State.js moves that wall dramatically.

The whole game state in Gutter Rat lives in one <div> opening tag. You can read the document and understand the entire data model without tracing through component trees or module imports. That inspectability is worth more than it sounds.


Try It

The full stack — Trig.js, Cursor.js, Keys.js, Motion.js, Gravity.js, State.js — is at iDev-Games on GitHub.


Tags: javascript css html gamedev webdev

Top comments (0)