Why rewrite something that worked
Skript let me ship fast. By the end of the first season the server ran on about 57 script files, around 816 KB of code, and it held 90 players at once. It also had limits I could not fix from inside Skript: no real types, no tests, shared global variables for everything, and every system reaching into every other.
Before porting anything I audited the old code line by line. The ledger came to 1,038 rows. The worst find was the conveyor paying out from two separate loops, so some players earned about ten times what the economy was balanced for. There were item dupes too. Every row became either a fix in the new code or a test that proves the bug cannot come back.
The shape of it
The new build is one core plugin and a jar per feature. The core owns the things every feature needs, and each feature is its own small plugin that can be built, tested and turned off on its own.
- lilies-api holds interfaces only. Features talk to each other through Bukkit's ServicesManager against those interfaces, never by reaching into each other's code.
- LiliesCore finds the jars in its features folder and loads them through Paper's own plugin manager, in dependency order.
- Feature jars are about 30 modules in all. Each one ships its own config, messages, items and menus.
Everything is a config file
Prices, drop rates, menu layouts and every line of chat text live in YAML, one folder per module. Staff can
run /lilies reload for everything or a single module, and the new values are checked before they
replace the old ones, so a typo leaves the running config in place. Small values can be changed in game with
/lilies set without opening a file.
Fast with a full server
- SQLite in WAL mode with a single async writer thread, so the main game thread never waits on the disk. Player profiles are cached in memory.
- Holograms and moving items are packet-only displays that the client interpolates, instead of entities teleported every tick.
- One shared per-second scheduler instead of a timer per system.
A load test with 50 simulated players harvesting 20 crops a second each costs 0.37 ms per tick. The server has 50 ms per tick to spend, and it held a steady 20 TPS.
Tests that caught real bugs
The suite had 501 tests once the core was done and 780 by the end of the money-loop phase. The most useful test was not a unit test at all. A boot test starts a real server with every jar loaded, and it caught a bug the unit tests could not see.
When a player right-clicks air, Paper fires the interact event already marked as cancelled. Seven handlers were set to skip cancelled events, so those features quietly ignored every air click. Every mock in the unit tests built the event uncancelled, so they all passed. The boot test used the real server and failed straight away.
Events without lost items
The events module runs 14 minigames. Joining one snapshots the player's inventory and state. Every way out of an event, whether winning, losing or leaving, restores that snapshot first. Prizes are paid only after the restore has been saved, so a crash in between cannot hand out a prize and wipe an inventory, or the other way round.
What I took from it
- Auditing before porting meant the new code fixed the old bugs instead of copying them.
- Mocks agree with your assumptions. A test against the real server is the one that argues back.
- Putting every number in config means balancing the economy stops needing a developer.