I got tired of shipping a build every time I moved an enemy
Changing a level in most games means shipping a build.
New enemy placement? Build. Boss too hard? Build. Wait for review. Wait for players to update. Discover the boss is now too easy? Congratulations, do it again.
This is silly, because a level is just data. So I put the data on a server and had the game ask for it. Change the data, every player gets the new level. No build, no review, no waiting.
That's Ludus. It's open source, it's early, and this is the story of building it — including three green checkmarks that turned out to be lying straight to my face.
The bit where I admit I already built this once
I'd written a level editor before, inside my own platform. Timeline you could drag movement blocks around on, a canvas that simulated the wave as you edited, multi-track audio, the works. Designers used it. It was good.
It was also completely unusable by anyone else, and the reason was one line:
export type EntityType = 'light';
One entity type. A union with exactly one member. Everything around it was general — eleven movement types, eight firing patterns, a whole progression model — but the one place it touched the actual game was nailed shut.
So I pulled it out into its own thing. Which was going to be a quick copy-paste job, obviously.
It was not a quick copy-paste job
The old code had layers. Real ones, with names — domain, application, infrastructure — and
a document explaining what went where.
It also had this:
class LightWaveService(
private val repository: LightWaveRepository, // concrete database class
private val audioRepository: LightAudioRepository, // also concrete
private val validator: WaveValidator
)
Those are concrete database classes, not interfaces. So application depended on
infrastructure, which is precisely backwards, and had been for ages.
Nobody did anything unreasonable here. There was just never a moment where the codebase said no. And because there was no interface at the seam, there was no way to swap the database layer out — which is the exact thing that would have made extraction easy.
Fine. Rewrite. Java 21, Spring Boot, and this time the layers are enforced by something other than my good intentions.
Making it impossible instead of discouraged
Seven Maven modules. The two inner ones declare zero framework dependencies — no Spring, no Jackson, no ORM, nothing.
Import Spring into the domain now and you don't get a review comment. You get this:
[ERROR] engine-application must not depend on a framework or on any adapter.
See docs/architecture/hexagonal.md
That's the good kind of feedback: it shows up in seconds, to the person who caused it, before anyone else is involved.
ArchUnit covers what the module graph can't see — dependency direction, annotations, that sort of thing. And then, because I am occasionally sensible, I broke both on purpose to watch them fail.
Which is how I found the first liar.
Three green checkmarks that were lying
The test suite that tested nothing
I wrote the architecture rules the way the docs show, as @ArchTest fields. Then I added an
obvious violation — a field injected with @Autowired, which one of the rules explicitly forbids.
Build passed. Green tick. Lovely.
Then I actually read the output:
Tests run: 0, Failures: 0, Errors: 0, Skipped: 0 -- in HexagonalArchitectureTest
Zero. It ran nothing. That style needs ArchUnit's own JUnit engine to be selected, and when it isn't, the class gets collected, reports success, and executes absolutely nothing. It had been "passing" since the moment I wrote it.
There was a second trap underneath, too: my import was skipping jars, and the sibling modules arrive as jars — so even once the rules ran they'd have analysed nearly nothing and passed anyway. Two layers of fake.
Now the rules are boring @Test methods that call check(), and the setup asserts that classes
from every layer were actually loaded before any rule runs. Six real tests. The violation fails
the build.
A guardrail nobody has watched fail is a guardrail nobody knows works.
The secret scanner that scanned nothing
Secret scanning runs on every push. First real run went red in two seconds — which is not enough time to read a repository:
FTL Failed to load config error="'Allowlist' expected a map, got 'slice'"
WRN scanned ~0 bytes (0)
I'd written a config array where it wanted a single table. The config failed to parse, so the scanner gave up before scanning. A red X that told me precisely nothing about whether the repo contained secrets.
And note the coin-flip there: it happened to fail closed. Wired slightly differently, that's a green tick meaning exactly as much.
The endpoint that didn't exist
My smoke test checked the metrics endpoint responded:
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
Passed every time. The endpoint did not exist.
Two separate bugs, cancelling out. Spring Boot 3.5 made metrics export opt-in, so it was never
registered. And Spring Security redirected my unauthenticated request to a login page, which the
test client cheerfully followed, and which returned a lovely 200 OK.
A test that asserts only a status code will happily accept a login form as proof your metrics work.
The bug that would've made everyone re-download everything
This one I caught before writing the code, which is the only reason it's not a fourth story.
Games cache content and revalidate with ETags. The ETag is a hash of the stored bytes. So the bytes have to be stable.
If the server parses a document and re-serialises it before saving, the bytes shift for reasons
that have nothing to do with content — key ordering, 1.0 versus 1, null handling. Store it as
jsonb and Postgres reshuffles them again.
Nothing breaks. Every save just mints a fresh ETag for content nobody touched, every client is told its cache is stale, and everyone re-downloads the lot. On mobile that's a real bandwidth bill and a slow trickle of reviews complaining the game is "always updating".
So the schema keeps documents exactly as they arrive, with a generated column alongside for indexing:
config_json text NOT NULL,
config jsonb GENERATED ALWAYS AS (config_json::jsonb) STORED
Written down while it's a paragraph, rather than later, when it'd be a migration.
So what's actually good about it
Your designer stops needing you. Content is data. Someone changes a level, it's live. No programmer, no build, no store review.
The editor and the game can't quietly disagree. The contract is one published JSON Schema, shared by both, validated on every build. Not a Java class one side happens to mirror.
Players don't re-download your catalogue every launch. The caching protocol is designed, not improvised, for exactly the reason above.
It runs on a cheap box. One service, one Postgres. No broker, no orchestrator, no five-container minimum. That ceiling is deliberate — you shouldn't need a platform team for a game with forty players.
Using it costs your game nothing. The engine is AGPL. The client SDK is Apache-2.0, because a copyleft library linked into your game would place obligations on your game, and no studio's lawyer is signing that. Server copyleft, permissive client — you can ship a commercial game on this without owing anybody anything.
How you'd use it, and what's coming
Today, honestly, you can look:
git clone https://github.com/MiladNalbandi/ludus-engine.git
cd ludus-engine
cp deploy/.env.example deploy/.env # set a password
docker compose -f deploy/docker-compose.yml up
That gets you a running service, health checks, and API docs at localhost:8080/docs. Read
contracts/schemas/wave/v1.json — that's the content contract — and samples/waves/ for worked
examples. There's no content API yet, so that's genuinely the extent of it.
Next few releases, each tracked as its own issue:
v0.1.0 | Sign in, API keys for game clients | #7 |
v0.2.0 | Author and serve content over HTTP, with proper caching | #8 |
v0.3.0 | The visual editor — timeline, live preview, audio | #9 |
v0.4.0 | XP, items, currency, inventory, leaderboards | #10 |
v1.1.0 | Define your own entity and behaviour types, as data | #12 |
v1.5.0 | Game client SDK | #16 |
That v1.1.0 line is the real destination. Right now the content model knows what a "wave" is.
The plan is that it stops knowing — entity types, behaviours and content types all become rows a
project defines, so your tower defence and my shooter run on the same engine with different data.
This is for you if you're building a 2D game, you're tired of shipping builds to move a number, and you'd rather influence a design than inherit one. Early is an advantage here; the decisions are still open.
This is not for you yet if you need to ship next month. There's no content API. Come back at
v0.2.0, or watch the roadmap.
I'd rather ship a foundation and say so than describe a product that doesn't exist.
github.com/MiladNalbandi/ludus-engine — stars are nice, issues are better, and if you tell me your game's content model doesn't fit, that's the most useful thing anyone could do right now.
