Skip to content

Add the Duel Arena minigame - #1274

Open
HarleyGilpin wants to merge 19 commits into
GregHib:mainfrom
HarleyGilpin:feat/duel-arena
Open

HarleyGilpin wants to merge 19 commits into
GregHib:mainfrom
HarleyGilpin:feat/duel-arena

Conversation

@HarleyGilpin

@HarleyGilpin HarleyGilpin commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Adds the Duel Arena - content/minigame/duel_arena

What's included

  • Challenge player option on the walkways/hospital (interface 640 friendly/staked selection, chat request, mutual-accept handshake like trading).
  • Rules screens 631 (staked) / 637 (friendly): all 12 rules plus the 11 equipment bans, mirrored to both players through the real 634 varbits, with the No Ranged/Melee/Magic and Obstacles/No Movement exclusions.
  • Staking through the 628 side tab into inventory 134, mirrored to the opponent's secondary container; tradeable items only.
  • Confirmation screens 626 / 639 with the before/during lists and stake summaries, then teleport into a random obstacle or obstacle-free arena (side-by-side placement for No Movement), banned gear unequipped, countdown "3, 2, 1, FIGHT!".
  • Enforcement: duellers and their familiars may only attack each other and only after the countdown; combat style, fun weapon, special attack, food, drink, prayer, summoning, movement, equipment, teleport, drop and logout blocks.
  • Ending by death, the forfeit trapdoor or disconnect: both players healed and sent to the hospital, winner gets 633/634 with the winnings in inventory 136 (claimed on close, bank fallback), audit log entries, and an in-memory scoreboard of the last 50 duels on object 3192.

Generic hooks were added to Eating, Equipping, Movement, Summoning and QuickPrayers (no_food_message, blocked_equip_slots, ...) because item/interface option handlers can't veto each other.

Tests

DuelTest (21 WorldTest cases) covers the request handshake, rule toggles and exclusions, stake mirroring/removal/restrictions, accept space and fun-weapon checks, countdown and fight start, death and forfeit payouts, disconnect forfeit, logout veto, walk-away decline, third-party attack blocking, leftover-item recovery and the scoreboard cap. Full :game:test passes.

@HarleyGilpin
HarleyGilpin marked this pull request as ready for review September 7, 2026 18:34
val opponent = duel.opponent(player)
if (duel.hasRule("fun_weapons") && !DuelRules.hasFunWeapon(player)) {
player.message("Fun Weapons is selected but you don't have a 'fun weapon'.")
opponent.message("Fun Weapons is selected but your opponent does not have a 'fun weapon'.")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should go to the player not the opponent

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b2232c0b0. canAccept now takes the accepting player and only messages them: "you don't have" for their own missing weapon, "your opponent does not have" for the other side. The opponent hears nothing until they click Accept themselves.

}
if (needed > player.inventory.spaces) {
player.message("You do not have enough space for the items removed and/or the stake.")
opponent.message("Your opponent does not have enough space for the items removed and/or the stake.")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
opponent.message("Your opponent does not have enough space for the items removed and/or the stake.")
player.message("Your opponent does not have enough space for the items removed and/or the stake.")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b2232c0b0, same shape as the fun weapon check: both messages go to the player who clicked Accept. Test updated so the opponent's copy only appears once they press Accept too.

Comment on lines +54 to +55
player.interfaces.sendText(screen, "stake", itemsText(player.stake))
player.interfaces.sendText(screen, "other_stake", itemsText(player.otherStake))

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This causes a ui bug because text doesn't need to be sent?

Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, cs2 206 fills both stake texts on 626 from the offer inventories itself. Removed both sendText calls and the now-unused itemsText in f625a0fc0.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the wrong one was removed?
image

for (rule in DuelRules.all) {
player.clear("duel_$rule")
}
player.attackers.clear()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hits need clearing as well.
If you shoot a killing blow with a bow over distance (aka against a low hp opponent), then quickly forfeit. You lose but the player get's hit after being teleported back to the arena, so they will end up losing their items and being sent to lumbridge.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. cleanup now clears each player's hit queue in 8f7b83476, so an arrow released before the forfeit never lands after the teleport. Added a test that queues a lethal ranged hit, forfeits through the trapdoor and checks the winner is alive at full HP in the hospital; it fails without the clear.

if (inventories.contains("duelwinnings")) {
returnItems(winnings)
}
save(this)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is resaving every player every-time they log in, rather than only when things are returned?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it was. Fixed in 1364ff9: playerSpawn now only queues a save when a leftover stake, mirrored stake or winnings actually had contents and were moved. A normal login touches nothing and doesn't save.

true
}

objectOperate("Forfeit", "duel_arena_forfeit_trapdoor", arrive = false) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does one forfeit when movement is disabled?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, it couldn't. The trapdoor was an objectOperate and with movement_delay running the interaction just waited forever.

On RS the doors could be clicked from anywhere in a No Movement duel (wiki: "If 'no movement' was enabled, the doors could simply be clicked to bring up the forfeit chatbox"). Done in 7343eb6: the trapdoor is now an objectApproach. In a No Movement duel the forfeit prompt opens from where the player stands, otherwise approachRange(-1) makes them walk up to it as before. Arenas have two doors on opposite ends of a ~15x11 area so the nearest one is always inside the default approach range. Tests cover both paths.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up in 1aa6034: the map trapdoors sit just outside the 17-wide arenas, so a frozen player can be 12 tiles away and the approach never launched within the default 10. objectApproach now takes an optional range that PlayerOnObjectInteract uses as its starting approach range; the trapdoor registers 16. Test moved to 12 tiles to cover it.

Component ids and the rule varbits (4157-4183, 4275 on varp 286) were
dumped from the 634 cache; Darkan's rule bit encoding and most of its
727 component ids don't match this revision.

teleport_block was declared on varbit 4163, which is the duel arena's
No Magic rule in 634, so it becomes a plain persisted variable.
Challenge requests, friendly and staked rules screens with all rules and
equipment bans, mirrored stakes, confirmation screens, arena placement
(obstacle/no-obstacle, no-movement side-by-side), countdown, rule
enforcement, forfeit trapdoor, logout veto with forfeit on disconnect,
victory screens with winnings, hospital healing and an in-memory
scoreboard of the last fifty duels.

Duellers and their familiars can only fight their opponent. Food, drink,
prayer, summoning, movement and equipment bans are enforced through
generic message/slot variables read by the existing skill scripts.
Clicking a duel request in the chat box makes the client send player
option 1, so the option has to live there like Attack does.
Interface 632 rebuilds its rows from varc strings 224-273 when it opens
and shows Loading... while all of them are blank, so the rows are sent
as those strings before opening instead of appended with a row script.
The victory screens copy that string over the name component when they
open, which replaced the text sent directly with null.
Varc 236 turns the worn equipment tab icon into the forfeit trapdoor
during a duel; the 634 cache has no forfeit tab interface for any other
slot, so clicking that tab now asks to forfeit like the trapdoor does.
Drop the equipment tab icon swap; the logout button now asks whether to
forfeit instead of refusing, and the trapdoor keeps working.
Queue a save of both players whenever stake items change hands so a
crash can't restore a pre-transfer snapshot, return leftover stakes
instead of clearing them, route the bank fallback through BankDeposit
so noted and unbankable items are handled, re-check inventory space at
the final accept, and treat every death inside an arena as drop-free so
simultaneous deaths never drop items.
HarleyGilpin and others added 5 commits October 3, 2026 08:35
With the No Movement rule the player is frozen so they could never walk
to the trapdoor and the click silently waited forever. The trapdoor is now
an approach interaction: in a No Movement duel the forfeit prompt opens
from wherever the player stands, otherwise they walk up to it as before.
Arenas are two doors on opposite ends of a ~15x11 area, so the nearest
door is always inside the default approach range.
Approach handlers only launch once the player is within the default ten
tiles, so a frozen player further away than that just waited forever.
objectApproach now takes an optional range which PlayerOnObjectInteract
uses as the starting approach range. The duel trapdoors sit outside the
17 wide arenas, up to 12 tiles from a No Movement player, so they use 16.

@GregHib GregHib left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spoils screen also combines the stake reward and the original amount staked, not sure if that's correct. E.g. staking 3k against 100k and winning will show the spoils as 103k which is not what you won.

@@ -0,0 +1,7 @@
[duel_arena_forfeit_trapdoor]
id = 3203

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two more forfeit objects, 3111 & 3113

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants