SEVER — one swipe, no take-backs

A one-finger committed-melee roguelite for Android and iOS. Built in Godot 4.7 / GDScript.


What inspired it

Mobile action games almost always resolve their control problem the same two ways: bolt a twin-stick layout onto a touchscreen, or take the attack away from the player entirely and auto-swing whenever something walks into range. The first is uncomfortable. The second is a bit inflexiblel; it quietly removes the decision that makes an action game an action game.

I wanted the opposite trade: the smallest possible input carrying the largest possible commitment.

That became the game's whole atom. In SEVER a swipe is your movement, your attack, your dodge and your gamble: all at once, all in one gesture. You dash along the vector you drew, your blade cuts everything on that path, and you are invulnerable only while you're travelling. Before the dash you're winding up and exposed. After it you're rooted in recovery and can't act. So a whiffed swing isn't a wasted button press — it's a debt you pay in the next half second.

The visual identity fell out of the same idea: the app icon is a severed ensō, an ink brush circle cut in two by a single tapered red slash. One draw, one cut, no second attempt. The whole game in one mark.

Six non-negotiable pillars keep it honest. The bluntest is: no auto-attack, ever. Proximity does not deal damage. Every fight in the game exists because a player chose to swing.


How I built it

7 months learning Godot, the idea for the game was from 10 years ago while I was still in high school, recently when moving places fell out of my notes and I took it as a sign.

Writing the code took roughly six-seven weeks, 185 commits, ~17,400 lines of GDScript across 114 files, currently at v1.2.1.

The architecture follows GDQuest's modular game architecture, with dependencies pointing strictly one way:

Layer Holds
Add-ons third-party plugins + my own engine-only helpers
Systems the run loop, shared bases, 13 autoload services, and pure RefCounted rule catalogs
UI every panel, built through a single skin-aware UiKit
Content the concrete cast — enemies, bosses, elite modifiers
Composition root the only file allowed to know every layer

Nothing depends on content/. The run loop spawns enemies by id through an injected registry, so adding an enemy is a row in one binding table — the run loop never changes. Nothing depends on ui/ at all.

I don't trust myself to hold that in my head, so I wrote tools/arch_check.py: it parses every script for both class_name references and res:// preloads, ignores strings and comments, prints the dependency matrix, and exits non-zero naming file:line on any violation.

Two conventions did more for stability than anything else:

  1. No wild string keys. Every id — loc keys, save keys, enemy archetypes, sprite keys, sound ids, entitlement ids, product ids — comes from a const in a designated class. A typo'd const is a compile error. A bare "chasr" is a group with no nodes that fails silently three weeks later.
  2. Domain logic is node-free. Gesture timing, health, the upgrade catalogs — all pure RefCounted. There's no formal test framework; instead I write a throwaway harness scene, run it headless, grep for RESULT / EXPECT-FAIL, and delete it but this will change soon enough.

I am a mobile engineer and borrowed some code concepts for keeping the code tidy and clean.

Everything ships in two complete skins — a 2D "ink" look by default and an 8-bit "retro" unlockable — which means every piece of visual content needs both an SVG and a pixel sprite, and every telegraph must read as shape plus motion, never colour alone. That constraint is annoying and it's also the single best accessibility decision in the project.


The money layer, and why I forked the SDK

Monetization is deliberately orthogonal to power. Cosmetics — skins, shield wards, robes, per-blade camos — plus exactly one consumable: a deep-floor death revive, which has a free rewarded-ad path alongside the paid one. The revive buys back time, never strength. Depth is the only thing that gates progression, and depth can't be bought.

Game code never touches a storefront SDK. It calls Store.buy(id) and reads ownership.

Upstream's Godot RevenueCat plugin could not support this catalogue, so I forked it https://github.com/FunkyMuse/godot-revenuecat and added three things:

  • get_active_entitlements() → the entitlement ids. Upstream exposed only a .size count, which tells you how many you own but not which — making ownership fundamentally unreconcilable.
  • get_purchased_products() → every purchased product id, including non-consumables carrying no entitlement. This is the one that mattered: it let à-la-carte items resolve by product-id instead of minting an entitlement per item, collapsing 26 entitlements down to 4.
  • It now actually emits customer_info_changed. Upstream declared the signal but never registered RevenueCat's listener — so a refund or chargeback could never reach the game. That signal is the live revoke path.

So that a player on a plane does not get their cosmetics confiscated.


Challenges

The floor-20 plateau. My own playtest died at floor 27 / wave 108 and I realised the last new enemy appeared around floor 5. Everything after that was the same roster with fatter HP bars — which violates a pillar outright ("pressure comes from more telegraphs and tighter timing, never bigger sponges"). The fix was to go generative: elite modifiers that recombine enemy behaviour, and scaling pacts that trade real power for real cost.

The "Â" bug. StoreKit returns localized prices with a non-breaking space between currency and amount. My pixel fonts have no glyph for U+00A0, and some SDK paths hand it over double-encoded — UTF-8 bytes read as Latin-1 — so the shop cheerfully displayed USD Â 0.99. The fix repairs the double-encoded forms first, then folds every non-breaking and thin space to plain ASCII. A day of my life for one whitespace character.


What I learned

That the interesting constraint is the product. "One finger" sounded like a limitation and turned out to be the entire design — it forced the commitment mechanic, which forced the i-frame window, which forced telegraphs to be readable, which made the game good.

That an abstraction boundary is worth building before you need it. Putting a StoreProvider interface between the game and RevenueCat felt like much needed on day one. It's why my headless tests, my CI, and my desktop editor all run with zero storefront — and why forking the SDK changed exactly one file.

And that the failure modes worth engineering for are the boring ones: the refund, the offline launch, the cancelled purchase, the double-encoded space. Nobody demos those. They're most of what separates a jam build from something you can put in front of a stranger.

Built With

  • gdscript
  • godot
  • kotlin
  • revenuecat
  • swift
Share this project:

Updates

Submission history