
The last big push touched two products at once: single-player became a looter-shooter, and an authoritative co-op server went from nothing to playable.
The previous devlog covered what changed and how it works. This one is about the part that actually matters — why each call was made, and what we traded away to make it.
Part I — Why Rainboids Became a Looter-Shooter
The Problem with the Old Progression
The old model gated content behind purchases: you ground currency to unlock access to weapons and abilities. That’s a grind for permission, and it has two failure modes: early players stare at locked content, and once everything is bought, the economy has nothing left to do. The reward loop dead-ends.
So the pivot reframes the grind. Everything is unlocked from the start, and the grind becomes loot quality instead of loot access. The question stops being “can I afford this weapon yet?” and becomes “is this drop better than what I’m holding?” — a question that never runs out.
Two Layers of Power, on Purpose
The single biggest structural decision: per-run level and skill points reset every run, while currency, gear, weapons, and Matrices persist.
This is deliberately two different fantasies stacked on top of each other. The per-run reset gives every session the roguelite weak-to-strong arc — you start fragile and earn power within the run. The persistent layer gives the looter/ARPG fantasy — your account gets permanently richer.
Keeping them separate means a session always has a satisfying internal climb and always contributes to the long game. Neither cannibalizes the other.
Gear Rewards Commitment, Not Breadth
Gear, Matrices, and set bonuses are never flat bonuses. They are percentage amplifiers of a skill-point stat you’ve invested in:
effective(stat) = SP_value(stat) × (1 + ampPct × levelRamp(level))
The reasoning is entirely in the choice of multiplicand. If gear gave flat stats, every piece would be universally good and builds would converge — you’d just stack the highest numbers.
By making gear amplify invested SP, a piece is only as valuable as your commitment to that stat. Pour nothing into a stat and the matching gear does literally nothing. The UI flags it INACTIVE.
This turns loot evaluation into a build question instead of a number-comparison, and it’s why Matrix resonance rewards running a matched set rather than scattering them. Same theme: depth beats breadth.
Protecting the In-Run Curve
There’s a tension hiding in persistent loot: if your stash is powerful, why is wave 1 ever hard? That would collapse the roguelite arc the two-layer model just bought.
The fix is the levelRamp term — gear amplification is dormant at level 1 and ramps to full only around level 25:
levelRamp(level) = clamp01((level − 1) / (25 − 1))
So a lucky drop can’t trivialize the opening of a run. Power still has to be earned within the session. The stash raises your ceiling, not your floor.
The whole point is to keep early waves honest while still rewarding a deep collection late.
Collapsing Three Systems into One
Weapons became rolled items — archetype + traits — and those traits fold three previously separate systems into one taxonomy:
Element, Behavior, Powerup, Stat.
The motivation was cognitive surface. Three overlapping systems asked players to learn three mental models for what are really all just “modifiers on a weapon.” One taxonomy is easier to reason about and makes “weapons as loot” coherent — a drop is a bundle of traits, full stop.
That collapse is also why the in-run card draft was removed. The game already had a “make an interesting choice” moment there, and weapons-as-loot plus drafted stages created two competing choice systems.
So the choice moment moved rather than duplicated: powerups became weapon traits, and the deliberate decision now lives in which drafted stage you take — each a risk/reward bundle whose modifiers feed the difficulty director — and how you manage your loadout, not in a mid-run card pick.
Bounding the Power Fantasy
When everything amplifies everything, stacking breaks the game. A 100% dodge chance is just invulnerability.
So amplified stats hit hard global caps: Dodge at 60%, Vampirism at 50%, and Thorns at 200%.
It’s a small thing, but it’s a conscious decision to keep risk on the table no matter how good the loot gets.
Why the Economy Is Built as Pure Math
Every formula above — income, gear scaling, trait rolls, caps — lives in pure modules with no DOM, no globals, and no Math.random.
Two reasons.
First, an economy you can’t reproduce is an economy you can’t balance. Deterministic functions mean a given build always yields the same numbers, so tuning is a dial rather than a guess. The income faucet is balanced against an explicit target: a 30-wave run lands near 39k currency by design, not by accident.
Second — and this turned out to matter enormously — pure sim code is portable.
Which leads directly into the multiplayer work.
Part II — Why the Multiplayer Looks the Way It Does
Authoritative Server, by Default
Co-op runs on an authoritative server: clients send input, the server simulates, clients render the result.
This is the boring, correct choice. It sidesteps the hardest distributed problems — consensus, cheating, conflicting truth — by simply having one source of truth.
Clients never decide anything that matters. They predict and reconcile.
Node.js, Not the Shelved Rust/WASM
An earlier multiplayer attempt used Rust/WASM and was deliberately abandoned.
The reason is a thesis that runs through this entire release: two implementations of the same game will drift.
A separate Rust sim means the multiplayer game slowly diverges from the single-player game in feel and bugs, and you pay to maintain both forever.
Choosing Node and pure JavaScript keeps multiplayer in the same language as the game it’s a copy of — and sets up the endgame where it’s literally the same code.
Optimizations That Live Below a Seam
The netcode sends delta snapshots — a full keyframe periodically, and field-level diffs in between — because most game state is static, idle, or slow-changing. Things like hp, radius, and type don’t need to be re-sent 60 times a second. That’s pure waste.
But the design decision isn’t “use deltas.” It’s where the deltas live.
The client reconstructs a full snapshot from each delta and hands that downstream, so interpolation, reconciliation, and rendering never know deltas exist.
Bandwidth optimization is kept as a pure transport concern behind a seam. The payoff: we can change or remove the optimization without touching a line of gameplay code.
The same philosophy put buildDelta and applyDelta in a single shared module with a round-trip test as its contract — because the classic netcode bug is a server and client that encode the same thing two slightly different ways.
Robustness Over Cleverness in the Codec
The wire format is a hand-rolled, dependency-free MessagePack encoder.
Going dependency-free was a deliberate trade: a bit more code we own in exchange for something that runs byte-identically in Node and the browser with no bundler, vendoring, or import-map friction.
The robustness choices are the telling ones.
decode returns null on malformed input instead of throwing, so one corrupt frame can’t crash a connection handler. It still accepts a legacy JSON string to keep tests and migration easy.
The handshake also rejects version-mismatched clients outright. Better a clean refusal than a silently corrupted room.
Just Enough Determinism
Each room seeds its own PRNG, but there is deliberately no cross-machine determinism requirement.
Lockstep determinism — every client reproducing the exact same simulation — is famously brittle. One floating-point divergence and the game desyncs.
Since the server is authoritative and clients merely interpolate authored entities, we don’t need it.
The seeded RNG buys the cheap, useful kind of determinism: reproducible server-side runs for replays, bug reports, and tests. It avoids paying for the expensive, fragile kind.
Knowing which determinism you don’t need is half the battle.
Part III — The One-Sim Bet
The Problem Worth the Risk
Right now the co-op server runs a small, separate simulation from the single-player game. Part II already named the consequence: two sims drift.
The most ambitious decision in this release is to commit to erasing that duplication entirely.
The goal: make multiplayer look and play exactly like single-player — with N ships — by running the actual single-player simulation, headless and authoritative, on the server, and rendering it with the actual single-player renderer.
One codebase, no second sim, no parity drift.
Why It’s a Refactor, Not a Rewrite — and Why That’s the Point
It would be tempting to rewrite the sim “cleanly” for the server.
We’re explicitly not doing that, because the single-player feel is the product, and it’s already battle-tested. A rewrite would silently re-introduce the exact drift we’re trying to kill.
The refactor is tractable because the engine already has the right shape:
update() is already a fixed-timestep tick that reads a constant dt, not wall-clock time. Every entity already splits update() from draw(). Systems are free functions called as fn.call(this).
So the move is to give those functions a headless engine context to bind to instead of the browser engine — the same logic, rebound.
Preserving the existing logic is the design decision.
The Gate That Governs Everything
Every step of this refactor is held to one rule: single-player must keep playing byte-identically.
This isn’t caution for its own sake. It’s what makes a whole-game refactor safe to do incrementally.
If single-player ever changes feel, a step was wrong, and you know immediately. The gate turns a terrifying rewrite into a sequence of provably behavior-preserving moves.
Side Effects as Injected Hooks
The sim shouldn’t know whether it’s being drawn.
So effects — particles, sound, screen-shake — are injected hooks: no-ops on the server, real implementations in the browser.
Instead of the server computing visuals nobody sees, each tick emits a stream of semantic events — death, hit, spawn — and the client re-derives the juice from those events.
Separating “what happened” from “how it feels” is what lets one body of logic run in a headless process and a browser unchanged.
State is what the server owns. Feeling is what the client renders.
Pragmatic Trade-Offs, Named Honestly
Two decisions here are explicitly “good enough,” and worth calling out because the reasoning is the interesting part:
- One sim per process. The deterministic clock and RNG are module-level globals the sim reads directly. Making them per-context would mean threading them through every reader — a huge, risky change. Instead, we accept process-per-room scaling, which the server architecture already assumed. Simpler, shippable, and not a dead end.
- Cosmetics don’t cost bandwidth. The server sends an asteroid’s position, angle, and radius — nothing about how it looks. The client regenerates the vertices, hue, and tumble deterministically from the entity’s id. Visual richness is reconstructed locally rather than transmitted, keeping the wire lean. And the nebula background is wrapped in a try/catch, on principle: a purely cosmetic layer should never be able to break the frame loop.
The Throughline
Three threads, one set of instincts:
Don’t run the same game twice. One sim, rendered in many places.
Reward commitment over breadth. Gear amplifies what you invest in, not everything.
Keep optimizations behind seams. Deltas, codecs, and headless contexts that gameplay code never has to know about.
None of it has fully shipped. The looter balance pass and BUILD-screen polish are still in flight, and multiplayer is firmly experimental – but the decisions are made, and the foundations are in.



Leave a Reply