5.2 KiB
Content Development Guide
How to add gameplay to 2009scape. Assumes you've read ../AGENTS.md and
ARCHITECTURE.md.
Golden rules: new content is Kotlin, lives under Server/src/main/content/,
implements a ContentInterface sub-type so ClassScanner auto-loads it, and should
match authentic January-2009 RuneScape behavior.
Where things go
| You're adding… | Put it under… |
|---|---|
| A quest / area NPC / area dialogue | content/region/<kingdom>/ (misthalin, asgarnia, kandarin, karamja, fremennik, morytania, desert, tirranwn, wilderness, misc) |
| A minigame / activity | content/minigame/<name>/ |
| Skill training logic | content/global/skill/<skill>/ |
| A cross-cutting item/interface/scenery handler | content/global/handlers/ |
| Shared data tables / enums | content/data/ |
| A player-runnable bot script | content/global/bots/ (see BOT_SCRIPTING.md) |
Follow the structure of the nearest existing example — the region tree alone has ~850 files, so there is almost always precedent to copy.
Reacting to interactions — InteractionListener
The workhorse for "player clicks/uses something." Implement InteractionListener and
override defineListeners(). The handler returns true if it handled the interaction.
class AttackListener : InteractionListener {
override fun defineListeners() {
on(IntType.NPC, "attack") { player, npc ->
player.attack(npc)
return@on true
}
}
}
Common builders (see core.game.interaction.InteractionListener):
on(id/ids, IntType.NPC|SCENERY|ITEM|GROUNDITEM, vararg options) { player, node -> ... }onUseWith(IntType, used, vararg with) { player, used, with -> ... }— "use X on Y"onUseAnyWith(IntType, vararg with)/onUseWithWildcard(...)— broader matches (wildcards cost overhead on every use-with; use sparingly)onEquip(id) { player, node -> ... }/onUnequip(...)flagInstant()— mark listeners that should fire without the walk-to delay
IntType values: NPC, SCENERY, ITEM, GROUNDITEM, PLAYER.
Interface/widget clicks — InterfaceListener
Implement InterfaceListener / defineInterfaceListeners() to handle button presses on
a specific interface (component) id, open/close hooks, etc.
Chat commands — Commands
Implement Commands / defineCommands(); register with define("name") { player, args -> }.
Gate access with Privilege (STANDARD / ADMIN / …). Use the provided reject(player, msg)
for usage errors and notify(player, msg) for output — reject colors the text red and
aborts the command.
class MyCommands : Commands {
override fun defineCommands() {
define("heal", Privilege.ADMIN) { player, _ ->
player.skills.updateLevel(Skills.HITPOINTS, /*...*/)
}
}
}
Command sets in the engine subclass CommandSet and are @Initializable; content-side
commands just implement Commands.
Lifecycle hooks
LoginListener/LogoutListener— per-player login/logout logic.TickListener— runs every game tick globally (keep it cheap).StartupListener/ShutdownListener— server boot/shutdown.PersistPlayer/PersistWorld— serialize custom player/world state to the save data.
NPC behavior
For per-NPC AI or combat overrides, extend NPCBehavior keyed by NPC id
(core.game.node.entity.npc.NPCBehavior). Used for custom aggression, combat scripts,
tick behavior, and death handling.
Dialogue
Dialogue lives beside its region/NPC (e.g. content/region/<kingdom>/dialogue/). Use the
dialogue system (DialogueFile / dialogueInterpreter) to send NPC lines, player lines,
and option menus. Look at existing *Dialogue.kt files for the current idiom.
Using the content API
Prefer the top-level helpers in core.api.ContentAPI.kt over engine internals:
sendMessage, sendDialogue, animate, visualize, addItem, removeItem,
hasAnItem, getStatLevel, rewardXP, teleport, getVarp/setVarp,
getAttribute/setAttribute, lock/unlock, playAudio, etc. These are the intended,
stable surface and keep content decoupled from the engine.
Player attributes support a /save: prefix (e.g. setAttribute("/save:my_flag", true))
to persist across logins.
IDs and data
- Reference game ids via
ConstLib:org.rs09.consts.Items,NPCs,Scenery,Animations,Components,Sounds, etc. — e.g.Items.FEATHER_314. - Static data (spawns, shops, drop tables, item configs) is JSON loaded at runtime. Edit with the Thanos tool (Java 11), not by hand.
Testing your content
- Run locally with
./run(no DB needed with default config). - Add JUnit tests under
Server/src/test/kotlin/for non-trivial logic — there's good precedent incontent/skill,content/region, and the core API tests. - Run
sh mvnw verifyto execute tests and detekt before opening an MR.
Before you open a merge request
- Kotlin only (unless fixing existing Java).
- Passes
mvn verify(tests + detekt clean). - Behavior is authentic to ~Jan 2009.
- Uses the default GitLab MR template (
.gitlab/issue_templates/Default.md).