New
Apps 1,382Makers 1,380This week 1,382
Promote your app−30%
Elusion RPG screenshot 1
0views in
the last 30 days

Elusion RPG

An RPG built by someone who had never written code a year ago.

Built with Claude Code

by tunacanman2ez · Unclaimed · added Oct 29, 2026
no ratings0 comments

A top-down 2.5D action RPG built in Godot 4.7 with GDScript, backed by a Flask account service. Four playable classes, six enemy families in forty-three elemental variants, collectable combat pets, fishing and cooking with recipes, a smith who tempers gear, player-to-player trading over a taxed economy, other players drawn live wherever you are and fighting the same monsters, and a complete run from town to final boss.

The final boss: two independent attack tracks telegraphing and erupting

The boss telegraphs in red, then erupts. Two attack tracks run on separate timers and never sync, so the pattern you are dodging is emergent rather than authored — and each spike leads you by its own telegraph time, which means a volley deforms in flight instead of landing where you were.

Built in the open, to learn from

Elusion is a labour of love and an educational showcase. I started it with no coding experience, and this repository is public on purpose: the whole game, along with the notes on what went wrong and why. If you are an indie developer or a student, you are welcome to read through it, take ideas from it, and learn from the mistakes as much as from the parts that work.

  • The code is free to use, change and learn from, under the MIT License in LICENSE.
  • The art is not. Some of it belongs to the artists who made it; License and assetlicense.md say what is whose.
  • Where to start: What's interesting in here below points at the parts worth reading first, and CLAUDE.md is the long record of every trap this project fell into and how it got out.

If something here helps you, I would love to hear about it.

The run

Register or log in → pick one of four class slots → town → through the portal to the Big Field, one long road with ten fields off it, a different element in each and harder the further you go → the ladder at its far end down to the Field, where every monster fights bullet-hell style (0.22.0, 0.23.0) → the gauntlet → the Crowned Beholder → the teleporter that opens in its room → home to town.

It has a beginning, an escalation, an ending, and a road back to the start.

Playing it

Elusion is played in a browser at play.elusionrpg.com, best with a keyboard and mouse. It also plays on a phone held sideways, with touch controls, though the screen is cramped there for now.

The first time a character stands in town, a welcome card gives the way out to the Field and lists every key. H brings the keys back at any time, and they are read from the game's own input settings, so the card cannot drift from the game. Change keys on that card (or on Options) turns the list into buttons: click one, press the new key. Every key but Escape and Enter can be moved, each action takes two, and a key taken from another action says so.

Options also sets the text size (up to 150%, for a big screen or one across the room) and the font: the standard one, or one of five that suit the game — Pixelify Sans, Tiny5, MedievalSharp, IM Fell English and Grenze Gotisch, each under the SIL Open Font License (see assetlicense.md). It also has a switch for other players' attacks: on by default; off, nobody else's waves, axes, spells, dynamite, orbs or auras are drawn, and a healer's aura still heals you, unseen.

Move W A S D, or the arrow keys
Sprint hold Shift
Attack, aiming with the mouse (with no mouse on the game, the way you face) Space, or left-click
Healer: drop a healing aura at the mouse right-click
Use the shop, bank, fire or fishing spot E
Use the item on a hotbar key 1 to 0
Bag, Gear, Stats, Map, Kills I, G, C, M, K
Open chat and type; send the line Enter
Close a window, or open Options Esc

Those are the keys as the game comes; Change keys moves any of them but Esc and Enter. Enter hands the keys back as it sends, so you can walk and talk without stopping, and chat left open goes see-through over the world until you type or point at it.

The version is in the login screen's corner and at the foot of Menu — MAJOR.MINOR.PATCH, 0.x until launch.

The bar along the bottom holds the windows a player opens all the time; Friends, Players, Guild, Party, Trade, the Kingdom board and Kills are under Social, and Controls, Options and logging out under Menu.

Party puts up to four players together: invite somebody by name or from the Players list, and the party is made when they accept. The window shows each member's class, level, area and health, the leader can hand the party on or remove somebody, and the party has its own chat tab. On the Map your party in the same area is drawn in the party's colour with their names, beside every monster on the ground you have explored, each in its element's colour.

Party voice: switch Voice on in the Party window and talk to your party, with an open mic or push-to-talk (V) and a mute key (B); a speaker lights beside the name of whoever is talking. Voice settings, in the same window: how loud everyone is and each player on their own (up to 200%), how loud your microphone is (up to 200%) with a live level bar, an open-mic sensitivity line so keys and the room stay out, and switches for noise suppression and echo cancellation. It is the browser's own calling, with its echo cancellation and noise suppression, and every call goes through a relay (Cloudflare's TURN), so no player ever sees another's internet address. It is in the browser version of the game.

Click a name - over a player's head, in the Players list or in chat - to whisper, add them as a friend, trade, invite them to your party or guild, or report them to staff.

The stirring lands: every three hours one of the seven lands — dark, light, ice, wind, earth, fire or water — stirs, and the whole server is told. While it does, that land's monsters glow in its colour, hit 25% harder, have 50% more health, move 15% faster and drop loot half again as often, and armour's resistance to its element counts half again as much (still capped at 50%). The server picks the land — the one hunted least in the last day, never the same one twice running — and a line at the top of the screen counts down to the next. The owner sets all of it on the GM panel's World tab: on or off, how long a stir lasts, each effect's switch and amount (there is a more-XP one, off as shipped), and a land to stir by hand. The world never stirs a land where a new player is learning - under level 10, with a kill there in the last two hours - while another land will do: a stir makes monsters tougher, and that is for players ready for it. The World tab says which land stirs next and why, offers it for Stir it now (or pick another), and its Lands window shows every land's week - kills, players, new players there now, when it last stirred or had an event, its busiest hour, and whether it is busy, quiet or ignored - with a chart of when players are usually on, in your own hours.

World events: double XP, more loot (1.25x to 3x, for every monster, one land's or the stirring land's) or a land made to stir for the whole event, from an hour to three days, set up to a year ahead on the GM panel's Events tab - from the day list or a calendar of any month and year, the days already taken in hotbar boxes - no new build. Everyone online is told when one is coming and again when it begins, with a banner; a line at the top of the screen counts down the ones on now and names the next. The server holds them and pays them at every kill (the API's events/ package, GET /api/world/events/active); two of a kind at once give the bigger, never both, and an event that cannot be read is no event rather than a kill unpaid. Never too much at once: no kill is paid more than 3x XP, 3x loot or 6x the two together, the stirring land's own counted in, and an event that could pass that with whatever else is on is refused when it is set, saying why. Dry run says what an event would do before it is set - allowed or not, the most any land's monsters would pay, and what 20 players might earn as usual and with it, from the last two weeks of real play. The Event report charts who was online this week with the events on it, and for each event shows the players online, XP and gold an hour, who played and who signed up in the window before it, during it and after it - and, across them, which kind of event brings players.

Kills (or K) is the kill record: every monster in the game with its picture, how many of each your character has killed and when the last one fell, and — on the Everyone tab — every player's kills added up, with who has killed the most of each. The server counts them; the pictures are cut from each monster's own art.

Who makes it

Elusion is made by Elusion Studios, and there are two of us:

  • Tunacan (Robert A. Clear), owner. I built Elusion solo up to launch, from zero coding experience: the code, the design and the server. Where this README says "I", it means me.
  • AllMind (Charles E. Clemmey Jr.), co-owner since 0.21.0. A self-taught coder, five years in, and a friend of more than ten years — we met in Mirage Realms and shared a guild. He gave Elusion its first push, some of the UI and the first inventory, and now we develop it together.

The artists, and everyone else whose work is in the game, are credited under License and in the game's Credits.

What's interesting in here

The parts I'd point a reviewer at first.

The client does not own the save — src/systems/savestorage.gd, src/systems/serverstorage.gd

CharacterData holds a SaveStorage and never asks which implementation it has. That seam is what let the game move from a local file to the Flask service without touching the twenty-nine places that call save_data(). The server is the authority now — it owns level, XP, the derived stat maxima and every loot roll — so the anti-tamper passes that existed to defend a local file against a text editor are skipped rather than run against data they can no longer say anything useful about. Writes are still debounced: saving on every stat change was firing about twenty times a second in normal play.

Enemies that form up instead of stacking — src/enemies/baseenemy.gd

Pathfinding runs on NavigationRegion2D, but the interesting part is slot claiming: each enemy reserves a specific tile in the ring around the player and paths to that, so a group arranges itself around you rather than piling into one spot. Movement stays strictly 4-directional despite using real pathfinding underneath.

They look and shoot at your body, not your node's origin, and the difference was a real bug. The two are 13 px apart on the warrior and 16 px on the tank, in opposite directions, so a warrior pressed against a wall above them had an origin inside the masonry: every enemy's line of sight hit the wall first, and the whole pack stood around holding fire. One helper, BaseEnemy.target_point(), now answers "where is the player" for every sight line and every shot, and a test presses each class against a wall from both sides.

One entity, two forms — src/enemies/poisonslime.gd

The poison slime duplicates once when you get close, then each copy converts into four smaller slimes at half health. Large and small are the same scene and the same script — a single is_small flag selects the animation set, the projectile, the stat block, and whether the duplicate and split paths are available at all. Its acid leaves ground hazards that stack, so the fight is about position rather than damage.

One sheet, seven creatures — src/shared/element.gd, src/shared/puddles.gd

Six enemy families exist as forty-three variants, and none of the extra art was drawn. A shader rotates the hue of a sprite's chromatic pixels and leaves its near-greys alone, so a green slime becomes a blue one without the black outline turning blue with it. There is a second, opposite shader for the boss: its spike is 84% grey rock, so hue rotation repaints sixteen highlight pixels and leaves the stone unchanged — that one tints the pixels below a saturation threshold instead. Which shader an asset takes was decided by measuring the sheet, not by eye, and both files carry the measurement. The sprites, bush mages and bush snipers had no death animation at all, so since 0.16.0 they flash white and break into pixels thrown up in their element's colour — drawn in code, so it costs no art either.

What keeps that from reading as seven coats of paint is that the colour is the least of it. Three separate layers decide what an element is, and they are deliberately orthogonal:

data/enemies/icebushsniper.tres     how hard it hits, how much HP   — a difficulty tier
scene/projectiles/icearrow.tscn     speed 320, scale 1.25           — how the shot moves
scene/projectiles/icepuddle.tscn    5.0s, 2 damage a tick           — what it leaves behind
ELEMENT_PROFILE in bossprojectile.gd   1.35 telegraph, 1.25 size    — how the boss's spike reads

Damage and HP rank identically in every family, because that axis is the tier ladder. Element character is the other axis: ice is slow, large and reaches furthest; wind is fast, small and brief; light is the quickest thing in the game and dark is the one you struggle to track. An ice arrow, an ice vine and an ice boss pillar all say the same thing about ice, which is what makes it learnable rather than decorative.

The fifty-seven variant scenes each author their own material rather than building one at spawn. A Material is a Resource, so one authored in a scene is shared by every instance of it — which means a boss cast that erupts sixty-five spikes now allocates nothing, where it used to allocate sixty-five. Two small registries map a base scene to its elemental variant, keyed on the base's resource_path so an @export pointed at something custom passes through untouched, and an element with no scene of its own falls back to the original and is recoloured at runtime. A missing file degrades; it does not break.

Ground hazards are the part that needed arithmetic rather than taste. A sixty-five-pillar cast every 2.16 seconds would cover 44% of the arena in standing acid if every pillar left one, so the roll is held at a third of that. Area goes as the square of scale, and two of the multipliers in that table exist purely to pay for a scene's size or spread rather than to express anything — water leaves three pools per roll, ice's pool is 1.56× the area — which is written down beside both of them, because a number that looks like character and is actually compensation is the kind that gets "tidied" back to 54% floor coverage a year later.

An economy that has to balance — gold_ledger (API repo), src/ui/trade/tradepanel.gd, src/ui/kingdom/kingdomboard.gd

Players can trade with each other, which is the point at which "the server owns the items" stops being enough. Two clients can now cooperate, and a duplication bug between them mints currency rather than moving it.

So gold is double-entry. Every creation and every destruction writes a row to gold_ledger, and one equation has to hold at all times:

SUM(gold_ledger.delta)  ==  SUM(saves.gold) + SUM(accounts.bank_gold)

The subtle part is what does not write a row. A player-to-player transfer creates nothing and destroys nothing — both purses are already inside the right-hand sum — so routing it through the ledger would net to zero and still pass. That makes the invariant alone a weak test, and test_economy.py asserts row counts and the minted total alongside it for exactly that reason. The suite mutates the ledger deliberately to confirm the check fails when it should.

It is also the kind of guarantee that can be true and useless at the same time, and I found that out the hard way. The invariant held across 268 tests while one endpoint let any player write their own balance directly — because every one of those tests reached the economy through a server path and then checked the books, and none of them tried the balance itself. The suite now attacks it head-on. An invariant only tells you about the paths somebody walked.

Trades are taxed, and the tax is the one place gold genuinely leaves the world. You are charged on what you receive, valued from the item table, gold included, and it is taken after the swap so gold you just received can pay for it. It rounds up with a floor of 1, which means splitting one trade into five is strictly more expensive than doing it once — the rounding is the anti-avoidance rule, not an accident of integer maths.

A sink nobody can see is a tax; a sink with a scoreboard is a contribution. GET /api/economy/kingdom sums those rows into a leaderboard, and the board recomputes on request rather than counting into a running total — a second place the truth lives is a first disagreement nobody can resolve.

Both sides confirm before anything moves, and a trade that fails validation at execution is refused through one path that marks it dead for both players. That one came out of a test I wrote speculatively: db.rollback() undoes the confirmation made in the current request and not the one the other player committed minutes ago, which left trades stuck half-confirmed until _trade_refuse() existed.

Confirming means the offer that was on the screen: every change moves a revision the accept has to name, so an offer swapped a moment before the click is refused rather than executed. And a trade changes two bags while only one player's request runs it, so both are flagged in the same transaction; until a client has been handed its new bag, a whole-bag save from it is refused. That is what stopped the player who accepted first from losing what they received the next time they moved a potion.

Real accounts, not a local profile gate — src/systems/api.gd

Passwords are salted and hashed server-side; the client holds a bearer token and never sees a hash. Login and "no such user" return an identical 401 so the endpoint can't be used to enumerate usernames. Rank is a ladder — player, mod, dev, co-owner, owner — decided on the server, and neither of the top two can be stored at all: both come from environment variables, so no request and no database edit can grant them. A co-owner also needs the owner's switch to be on (next section).

Staff tools that actually reach the player — src/ui/staff/staffpanel.gd, src/ui/characterhud.gd

Mods, devs, a co-owner and the owner get a Staff button on the HUD: every account with who is online right now, and kick, ban (1, 7 or 30 days, or permanent), unban, promote and demote. The panel decides nothing — it only hides what the server would refuse. A mod cannot touch a mod, only a dev or above bans permanently, and nobody grants a rank at or above their own. And no staff rank can create an item: giving yourself one, and writing a whole bag or bank at once, are the owner's alone — and a co-owner's, while the owner lets him in.

The interesting part was what a kick looked like from the other side: nothing. The server deleted the session, but the game never asked again after the login screen, so a kicked player played on until they restarted. The game now sends a heartbeat every fifteen seconds and re-checks at once on any refused request — a kick lands in about a second — and only a 401 counts, so restarting the server never signs everyone out.

The owner's GM panel has a Testing tab: gold and items by id, an Item catalogue

Description taken from the repository README.

Ratings

–
0 ratings
5 0
4 0
3 0
2 0
1 0

Rate this app

Sign in to rate

Comments 0

Sign in to comment

No comments yet.

Related apps

#games · #rpg · #browser-game