-
-
Combat: the hero fights on its own; abilities and talents shape it.
-
While you were away: offline progress replays the hero's measured rate.
-
Talent trees: three specialisations per class.
-
The ladder: real players' characters, ranked, and duelable.
-
Gear: tier and rarity are palette swaps on one sprite rig.
-
App icon, 1024×1024, uncropped.
Inspiration
I like the shape of an MMO: a class, a talent tree, gear that tells you where you have been, other people's characters walking past. What I can't do any more is give one forty hours a week. Idle games solve the time problem, but most of them do it by throwing away everything that made the MMO worth playing and replacing it with a number going up behind a timer.
Stillmarch is an attempt at the other trade. Keep the MMO parts that are decisions (the build, the gear, the route, which fights are worth picking) and automate the parts that are just time. The hero keeps marching whether you watch or not, and the game is honest about what that absence was worth.
What it does
You create a hero from four classes and eight races and send them into a twelve-zone world rendered as a dimetric pixel-art map. Combat resolves on its own: the hero finds targets, fights, loots and moves on, and you step in where it matters, including abilities on cooldown, talent choices across three specialisation trees per class, gear, gathering, smithing, and when to push into the next zone.
Close the app and the hero carries on. When you come back, a report itemises what happened: kills, gold, experience, levels, what was equipped, what was carried home and what was sold. Up to eight hours are free. The Standing Orders purchase extends that permanently by another eight.
Other players are real. Every hero publishes a snapshot (class, level, gear, full talent allocation) to a shared ladder, ranked by power and by honor. You can duel anyone on it. Those snapshots also walk your world as "ghosts", real players' characters wearing their real gear, which you can fight for honor. Nobody has to be online at the same time, and nothing that happens to a ghost is written back to its owner.
There are no loot boxes, no energy timers and no premium currency. There are two optional one-time purchases: the Founder Pack (cosmetic colourways) and Standing Orders (a longer offline window).
How I built it
Stack. React 18 and TypeScript with Zustand for game state, Vite for the build, Tailwind for the HUD. The world is drawn on a Canvas 2D context, not in the DOM. Capacitor 8 wraps the same bundle for iOS and Android. The one build-time difference is the asset base: ./ for native, because the WebView serves from capacitor://localhost at the root. Supabase (Postgres plus a Deno Edge Function) backs the ladder. RevenueCat handles purchases. Ambient audio is procedural Web Audio via Tone.js, driven by the current biome.
Palette-indexed sprites. This is the decision the rest of the art pipeline hangs from. Every sprite is authored as a PNG plus a JSON manifest carrying its source palette. At load time the sheet is decomposed back into palette slot indices by exact colour match, with a nearest-colour fallback for stray antialiased pixels. After that the renderer never sees a colour, only (spriteId, paletteId).
The palette is 16 slots with fixed meaning:
- 0: transparent
- 1–4: skin ramp
- 5–8: primary material
- 9–12: secondary material
- 13–15: accent, reserved as the rarity signal
Heroes are 24×32 frames in 13-frame strips (idle, walk, attack, hit, death), authored in two facings; the renderer flips for the other two.
Variants fall out of the slots. A palette registry composes every gameplay concept as slot overrides on a base:
- Race rewrites 1–4.
- Gear tier (t1 leathers through t4 arcane) rewrites 5–12.
- Rarity (common through legendary) rewrites only 13–15, so it composes with any tier:
gear/t3+epicis t3's materials with epic's accent. - Item icons follow the same rule. Each slot has one icon rig, and a sword reads as epic because its accent pixels, such as the pommel and the gem, sit in 13–15.
- A biome-ambient layer mixes every slot toward the ground colour of the zone the unit stands in, with a contrast floor so the hero never turns into camouflage.
None of these needed new art. One rig becomes race × tier × rarity × biome combinations at zero marginal pixel cost, which is the only way one person could fill a twelve-zone world.
Recolouring is paid once per (sprite, palette) pair at first use, never per frame. Baked sheets sit in an LRU cache capped at 64 for the mobile Safari memory budget. The scene pools entities, depth-sorts by feet position with an in-place insertion sort (the list is nearly sorted frame to frame), and allocates nothing in the render pass.
The cosmetic SKU is the same mechanism. A colourway is a set of overrides on slots 5–15, never 1–4, so it reads as gear rather than as a different person. The Founder Pack is three of them behind one RevenueCat entitlement, founder, checked in exactly one function. No extra art was drawn for the paid content. It is data.
RevenueCat, two non-consumables. @revenuecat/purchases-capacitor with two entitlements:
founderunlocks the colourways.standing_ordersadds eight permanent hours to the offline cap.
Entitlements are read live from the billing cache on every load rather than stored in the save, so a refund takes effect on its own. Every call is guarded: any failure resolves to "no entitlements", and the game never blocks on the store.
A 2.1(b) rejection ("products failed to load") taught me that an Offering is dashboard state, a single point of failure the client can't see. The paywall now prefers the current Offering but falls back to getProducts() against the compiled product IDs, asking StoreKit directly. It also shows the specific reason when a price is missing, so a misconfiguration can be told apart from a StoreKit outage.
Offline progress replays a measured rate. It doesn't re-simulate. Re-simulating eight hours honestly would take about 115,000 sub-second combat ticks, too slow on a phone. Instead the live game keeps an exponentially decaying record (τ = 600 s) of the gold, XP, kills and elite kills this hero actually produced. On load, that rate is replayed over the absence. Drops are re-rolled through the same loot ladder the live game uses (wear, sell, bag, salvage), and levelling stops at the ceiling of the content the player has unlocked. A headless harness asserts two properties: offline never out-earns playing, and a settled hero comes back to within a few percent of what watching would have paid.
The async ladder. Each client upserts one roster_snapshots row: name, class, level, race, all six equipment slots and the full talent allocation. Postgres row-level security restricts a client to its own row. There are two boards, power and honor.
A duel is resolved server-side by a Supabase Edge Function that imports the same pure, seeded TypeScript runFight the client uses. It is deterministic via a seeded mulberry32 stream, and a sim proves byte-identical replay. The server returns only the seed and both snapshots. The client replays the fight locally to animate it, then checks its winner against the server's. A mismatch means the two builds run different combat code, and the UI says so instead of showing a fight that contradicts its own result. The same snapshots feed the ghosts that walk your world. There is one combat implementation for all three uses.
Moderation. Player names go through a word filter at creation. The ladder has flag, report and block, backed by Supabase tables. Both were required under App Review guideline 1.2.
Challenges I ran into
Scaling the art alone. A class-and-race MMO wants a combinatorial amount of art: every race in every armour tier at every rarity, in every zone. Drawing that is impossible for one person, and the obvious shortcut of tinting whole sprites produces mud. The answer was to make the palette contract the source of truth before drawing anything: fixed slot meanings, with the rarity channel reserved so it can never collide with materials. That made race, tier, rarity, biome lighting and the paid cosmetics all the same operation. The cost is discipline. A sprite whose colours aren't slotted correctly still renders, but it can never be recoloured, so the import tooling has to enforce the contract.
Shipping inside a fixed window. The release had a date, and App Review didn't care about it. The first submissions came back on three counts:
- 2.1(b), products failed to load. The cause was an unconfigured RevenueCat Offering, not the app, and six client builds could not fix it. Fixed with the StoreKit fallback above.
- 1.2, user-generated content, because the ladder shows other players' names. This needed a terms gate, a word filter, and report and block.
- 2.3.6, the age-rating questionnaire, because the game shows a chat UI.
Each fix had to be small enough to ship without destabilising what already worked. Screenshots and review recordings had to be reproducible on demand, so they are driven from seeded save states by script rather than played to by hand.
Accomplishments that I'm proud of
- One sprite rig serving every race, gear tier, rarity and biome, and a paid cosmetic that is a few lines of palette data rather than new art.
- One combat implementation shared by the client, the server-side duel resolver and the ghosts in the world, with a check that catches version skew between players.
- Offline progress with measured parity against playing, and a guarantee that closing the app is never the optimal strategy.
What I learned
Put the constraint in the data format before producing content against it. The 16-slot contract cost a day to decide and saved every art task after it.
Anything configured in a dashboard is part of the system under test, even when the code is correct.
What's next
- Replacing the remaining placeholder unit art with the original set now being produced against the slot contract, which also makes more colourways possible.
- More colourway packs, since each one is data.
- The Android build on Google Play (the native project is done and submitted).
- A hosted proxy for the optional LLM-written flavour text, which is stripped from store builds today.
Built With
- android
- capacitor
- deno
- html5
- ios
- node.js
- postgresql
- react
- revenuecat
- storekit
- supabase
- tailwindcss
- tone.js
- typescript
- vite
- web-audio-api
- zustand
Log in or sign up for Devpost to join the conversation.