diff --git a/src/Matchmaking/Interfaces/IKoEarlyRequeue.cs b/src/Matchmaking/Interfaces/IKoEarlyRequeue.cs
new file mode 100644
index 00000000..b030f16c
--- /dev/null
+++ b/src/Matchmaking/Interfaces/IKoEarlyRequeue.cs
@@ -0,0 +1,19 @@
+using SS.Core;
+using SS.Matchmaking.TeamVersus;
+
+namespace SS.Matchmaking.Interfaces
+{
+ ///
+ /// Interface that allows external modules to signal that a KO'd player was early-requeued into a new match,
+ /// so that the old match's end-of-match cleanup skips calling UnsetPlayingByName for them.
+ ///
+ public interface IKoEarlyRequeue : IComponentInterface
+ {
+ ///
+ /// Marks that the specified player was early-requeued out of the given match.
+ /// End-of-match cleanup for that match will then skip removing the player from the Playing state,
+ /// preserving their entry for any newer match they have joined.
+ ///
+ void MarkPlayerKoEarlyRequeued(IMatchData matchData, string playerName);
+ }
+}
diff --git a/src/Matchmaking/Interfaces/IRecklessPlayPenalty.cs b/src/Matchmaking/Interfaces/IRecklessPlayPenalty.cs
new file mode 100644
index 00000000..b5a567c2
--- /dev/null
+++ b/src/Matchmaking/Interfaces/IRecklessPlayPenalty.cs
@@ -0,0 +1,17 @@
+using SS.Core;
+using SS.Matchmaking.TeamVersus;
+
+namespace SS.Matchmaking.Interfaces
+{
+ ///
+ /// Interface for querying whether a player has a pending reckless play penalty for a match.
+ ///
+ public interface IRecklessPlayPenalty : IComponentInterface
+ {
+ ///
+ /// Returns if the specified player has a pending reckless play penalty
+ /// recorded for the given match (i.e. they were KO'd too quickly and the match has not yet ended).
+ ///
+ bool HasPendingPenalty(IMatchData matchData, string playerName);
+ }
+}
diff --git a/src/Matchmaking/Modules/KoRequeue.cs b/src/Matchmaking/Modules/KoRequeue.cs
new file mode 100644
index 00000000..2a076420
--- /dev/null
+++ b/src/Matchmaking/Modules/KoRequeue.cs
@@ -0,0 +1,248 @@
+using SS.Core;
+using SS.Core.ComponentInterfaces;
+using SS.Matchmaking.Callbacks;
+using SS.Matchmaking.Interfaces;
+using SS.Matchmaking.TeamVersus;
+
+namespace SS.Matchmaking.Modules
+{
+ ///
+ /// Module that allows KO'd players to re-enter the matchmaking queue after a configurable cooldown,
+ /// potentially joining a new match before their old one finishes.
+ ///
+ /// Early requeue is only granted if the player has no pending reckless play penalty for the match.
+ /// The player's auto-requeue preference is respected: if they have auto-requeue enabled they are
+ /// placed back in the queue automatically; otherwise they must type ?next again.
+ ///
+ /// For use with the module.
+ ///
+ [ModuleInfo($"""
+ Allows KO'd players to re-enter the matchmaking queue after a configurable cooldown.
+ For use with the {nameof(TeamVersusMatch)} module.
+ """)]
+ public sealed class KoRequeue : IModule, IArenaAttachableModule
+ {
+ private readonly IChat _chat;
+ private readonly IConfigManager _configManager;
+ private readonly ILogManager _logManager;
+ private readonly IMainloopTimer _mainloopTimer;
+ private readonly IPlayerData _playerData;
+
+ private IPlayManager? _playManager;
+ private IKoEarlyRequeue? _koEarlyRequeue;
+ private IRecklessPlayPenalty? _recklessPlayPenalty; // optional
+
+ private readonly Dictionary _arenaDataDictionary = new(Constants.TargetArenaCount);
+
+ public KoRequeue(
+ IChat chat,
+ IConfigManager configManager,
+ ILogManager logManager,
+ IMainloopTimer mainloopTimer,
+ IPlayerData playerData)
+ {
+ _chat = chat ?? throw new ArgumentNullException(nameof(chat));
+ _configManager = configManager ?? throw new ArgumentNullException(nameof(configManager));
+ _logManager = logManager ?? throw new ArgumentNullException(nameof(logManager));
+ _mainloopTimer = mainloopTimer ?? throw new ArgumentNullException(nameof(mainloopTimer));
+ _playerData = playerData ?? throw new ArgumentNullException(nameof(playerData));
+ }
+
+ #region Module members
+
+ bool IModule.Load(IComponentBroker broker)
+ {
+ _playManager = broker.GetInterface();
+ if (_playManager is null)
+ {
+ _logManager.LogM(LogLevel.Error, nameof(KoRequeue), $"Unable to get {nameof(IPlayManager)}.");
+ return false;
+ }
+
+ _koEarlyRequeue = broker.GetInterface();
+ if (_koEarlyRequeue is null)
+ {
+ _logManager.LogM(LogLevel.Error, nameof(KoRequeue), $"Unable to get {nameof(IKoEarlyRequeue)}.");
+ broker.ReleaseInterface(ref _playManager);
+ return false;
+ }
+
+ // Optional: if RecklessPlayPenalty is not loaded, all KO'd players are eligible for early requeue.
+ _recklessPlayPenalty = broker.GetInterface();
+
+ return true;
+ }
+
+ bool IModule.Unload(IComponentBroker broker)
+ {
+ if (_recklessPlayPenalty is not null)
+ broker.ReleaseInterface(ref _recklessPlayPenalty);
+
+ broker.ReleaseInterface(ref _koEarlyRequeue);
+ broker.ReleaseInterface(ref _playManager);
+ return true;
+ }
+
+ #endregion
+
+ #region IArenaAttachableModule members
+
+ [ConfigHelp("SS.Matchmaking.KoRequeue", "Enabled", ConfigScope.Arena, Default = false,
+ Description = "Set to 1 to enable early requeue for KO'd players.")]
+ [ConfigHelp("SS.Matchmaking.KoRequeue", "CooldownSeconds", ConfigScope.Arena, Default = 30,
+ Description = "Seconds after KO before the player may queue for a new match.")]
+ bool IArenaAttachableModule.AttachModule(Arena arena)
+ {
+ ArenaData arenaData = new();
+ arenaData.Enabled = _configManager.GetBool(arena.Cfg!, "SS.Matchmaking.KoRequeue", "Enabled", false);
+ arenaData.Cooldown = TimeSpan.FromSeconds(_configManager.GetInt(arena.Cfg!, "SS.Matchmaking.KoRequeue", "CooldownSeconds", 30));
+
+ if (arenaData.Enabled && arenaData.Cooldown <= TimeSpan.Zero)
+ {
+ _logManager.LogM(LogLevel.Warn, nameof(KoRequeue),
+ $"[{arena.Name}] CooldownSeconds must be positive. KO'd players will be immediately early-requeued.");
+ }
+
+ _arenaDataDictionary.Add(arena, arenaData);
+
+ TeamVersusMatchPlayerKilledCallback.Register(arena, Callback_TeamVersusMatchPlayerKilled);
+ TeamVersusMatchEndedCallback.Register(arena, Callback_TeamVersusMatchEnded);
+
+ return true;
+ }
+
+ bool IArenaAttachableModule.DetachModule(Arena arena)
+ {
+ TeamVersusMatchPlayerKilledCallback.Unregister(arena, Callback_TeamVersusMatchPlayerKilled);
+ TeamVersusMatchEndedCallback.Unregister(arena, Callback_TeamVersusMatchEnded);
+
+ _arenaDataDictionary.Remove(arena);
+ return true;
+ }
+
+ #endregion
+
+ #region Callbacks
+
+ private void Callback_TeamVersusMatchPlayerKilled(IPlayerSlot killedSlot, IPlayerSlot killerSlot, bool isKnockout)
+ {
+ if (!isKnockout)
+ return;
+
+ IMatchData matchData = killedSlot.MatchData;
+ Arena? arena = matchData.Arena;
+ if (arena is null)
+ return;
+
+ if (!_arenaDataDictionary.TryGetValue(arena, out ArenaData? arenaData) || !arenaData.Enabled)
+ return;
+
+ string? playerName = killedSlot.PlayerName;
+ if (string.IsNullOrEmpty(playerName))
+ return;
+
+ // If the player has a pending reckless play penalty, they forfeit early requeue.
+ // NOTE: this relies on RecklessPlayPenalty being attached before KoRequeue in AttachModules,
+ // so that its kill callback has already recorded the penalty before this one fires.
+ // A defensive second check is also performed in the timer callback.
+ if (_recklessPlayPenalty?.HasPendingPenalty(matchData, playerName) == true)
+ return;
+
+ Player? player = killedSlot.Player;
+ if (player is not null)
+ {
+ _chat.SendMessage(player,
+ $"You have been knocked out. You may queue for a new match in {FormatDuration(arenaData.Cooldown)}.");
+ }
+
+ _mainloopTimer.SetTimer(MainloopTimer_KoCooldown, (int)arenaData.Cooldown.TotalMilliseconds, Timeout.Infinite,
+ new KoTimerContext(matchData, playerName), matchData);
+
+ _logManager.LogM(LogLevel.Info, nameof(KoRequeue),
+ $"[{arena.Name}] [{playerName}] KO'd; early requeue cooldown started ({arenaData.Cooldown.TotalSeconds:F0}s).");
+ }
+
+ private void Callback_TeamVersusMatchEnded(IMatchData matchData, MatchEndReason reason, ITeam? winnerTeam)
+ {
+ // Cancel any pending cooldown timers for this match.
+ _mainloopTimer.ClearTimer(MainloopTimer_KoCooldown, matchData);
+ }
+
+ #endregion
+
+ #region Timer
+
+ private bool MainloopTimer_KoCooldown(KoTimerContext context)
+ {
+ IMatchData matchData = context.MatchData;
+ string playerName = context.PlayerName;
+
+ // Defensive check: verify the player's slot is still in KnockedOut status.
+ // (A sub could theoretically fill the slot before the timer fires.)
+ bool stillKnockedOut = false;
+ foreach (ITeam team in matchData.Teams)
+ {
+ foreach (IPlayerSlot slot in team.Slots)
+ {
+ if (string.Equals(slot.PlayerName, playerName, StringComparison.OrdinalIgnoreCase))
+ {
+ stillKnockedOut = slot.Status == PlayerSlotStatus.KnockedOut;
+ goto foundSlot;
+ }
+ }
+ }
+ foundSlot:
+
+ if (!stillKnockedOut)
+ return false;
+
+ // Defensive check: re-verify no reckless play penalty was recorded.
+ // The kill callback already checks this, but that check depends on RecklessPlayPenalty's
+ // callback having fired first. Checking again here makes correctness order-independent.
+ if (_recklessPlayPenalty?.HasPendingPenalty(matchData, playerName) == true)
+ return false;
+
+ // Free the player from the Playing state so they can queue again.
+ // allowRequeue: true respects the player's auto-requeue preference:
+ // - if auto-requeue is on → they are placed back in queue automatically
+ // - if auto-requeue is off → PreviousQueued is cleared; they must type ?next
+ _playManager!.UnsetPlayingByName(playerName, allowRequeue: true);
+
+ // Mark this in the old match's participation record so EndMatch skips them.
+ _koEarlyRequeue!.MarkPlayerKoEarlyRequeued(matchData, playerName);
+
+ Player? player = _playerData.FindPlayer(playerName);
+ if (player is not null)
+ {
+ _chat.SendMessage(player, "You are now free to queue for a new match.");
+ }
+
+ _logManager.LogM(LogLevel.Info, nameof(KoRequeue),
+ $"[{matchData.ArenaName}] [{playerName}] Early requeue cooldown elapsed; player freed from Playing state.");
+
+ return false; // one-shot timer
+ }
+
+ #endregion
+
+ private static string FormatDuration(TimeSpan duration)
+ {
+ int totalSeconds = Math.Max(0, (int)duration.TotalSeconds);
+ int minutes = totalSeconds / 60;
+ int seconds = totalSeconds % 60;
+ return minutes > 0 ? $"{minutes}m {seconds}s" : $"{seconds}s";
+ }
+
+ private sealed class ArenaData
+ {
+ public bool Enabled;
+ public TimeSpan Cooldown;
+ }
+
+ private sealed class KoTimerContext(IMatchData matchData, string playerName)
+ {
+ public IMatchData MatchData { get; } = matchData;
+ public string PlayerName { get; } = playerName;
+ }
+ }
+}
diff --git a/src/Matchmaking/Modules/RecklessPlayPenalty.cs b/src/Matchmaking/Modules/RecklessPlayPenalty.cs
index c742961f..504a64bb 100644
--- a/src/Matchmaking/Modules/RecklessPlayPenalty.cs
+++ b/src/Matchmaking/Modules/RecklessPlayPenalty.cs
@@ -24,7 +24,7 @@ public sealed class RecklessPlayPenalty(
IConfigManager configManager,
ILogManager logManager,
IPlayerData playerData,
- IPlayManager playManager) : IModule, IArenaAttachableModule
+ IPlayManager playManager) : IModule, IArenaAttachableModule, IRecklessPlayPenalty
{
private readonly IChat _chat = chat ?? throw new ArgumentNullException(nameof(chat));
private readonly IConfigManager _configManager = configManager ?? throw new ArgumentNullException(nameof(configManager));
@@ -32,17 +32,21 @@ public sealed class RecklessPlayPenalty(
private readonly IPlayerData _playerData = playerData ?? throw new ArgumentNullException(nameof(playerData));
private readonly IPlayManager _playManager = playManager ?? throw new ArgumentNullException(nameof(playManager));
+ private InterfaceRegistrationToken? _iRecklessPlayPenaltyToken;
+
private readonly Dictionary _arenaDataDictionary = new(Constants.TargetArenaCount);
#region Module members
bool IModule.Load(IComponentBroker broker)
{
+ _iRecklessPlayPenaltyToken = broker.RegisterInterface(this);
return true;
}
bool IModule.Unload(IComponentBroker broker)
{
+ broker.UnregisterInterface(ref _iRecklessPlayPenaltyToken);
return true;
}
@@ -107,6 +111,23 @@ bool IArenaAttachableModule.DetachModule(Arena arena)
#endregion
+ #region IRecklessPlayPenalty members
+
+ bool IRecklessPlayPenalty.HasPendingPenalty(IMatchData matchData, string playerName)
+ {
+ Arena? arena = matchData.Arena;
+ if (arena is null)
+ return false;
+
+ if (!_arenaDataDictionary.TryGetValue(arena, out ArenaData? arenaData))
+ return false;
+
+ return arenaData.PendingPenalties.TryGetValue(matchData, out Dictionary? matchPenalties)
+ && matchPenalties.ContainsKey(playerName);
+ }
+
+ #endregion
+
#region Callbacks
private void Callback_TeamVersusMatchPlayerKilled(IPlayerSlot killedSlot, IPlayerSlot killerSlot, bool isKnockout)
diff --git a/src/Matchmaking/Modules/TeamVersusMatch.cs b/src/Matchmaking/Modules/TeamVersusMatch.cs
index 8bfd5459..ee6a90fb 100644
--- a/src/Matchmaking/Modules/TeamVersusMatch.cs
+++ b/src/Matchmaking/Modules/TeamVersusMatch.cs
@@ -55,7 +55,7 @@ namespace SS.Matchmaking.Modules
Manages team versus matches.
Configuration: {nameof(TeamVersusMatch)}.conf
""")]
- public sealed class TeamVersusMatch : IAsyncModule, IMatchmakingQueueAdvisor, IFreqManagerEnforcerAdvisor, IMatchFocusAdvisor, ILeagueGameMode, ILeagueHelp
+ public sealed class TeamVersusMatch : IAsyncModule, IMatchmakingQueueAdvisor, IFreqManagerEnforcerAdvisor, IMatchFocusAdvisor, ILeagueGameMode, ILeagueHelp, IKoEarlyRequeue
{
private const string ConfigurationFileName = "TeamVersus.conf";
@@ -87,6 +87,7 @@ public sealed class TeamVersusMatch : IAsyncModule, IMatchmakingQueueAdvisor, IF
private AdvisorRegistrationToken? _iMatchFocusAdvisorToken;
private AdvisorRegistrationToken? _iMatchmakingQueueAdvisorToken;
+ private InterfaceRegistrationToken? _iKoEarlyRequeueToken;
private ConfigHandle? _teamVersusConfig;
@@ -265,6 +266,7 @@ async Task IAsyncModule.LoadAsync(IComponentBroker broker, CancellationTok
_iMatchFocusAdvisorToken = broker.RegisterAdvisor(this);
_iMatchmakingQueueAdvisorToken = broker.RegisterAdvisor(this);
+ _iKoEarlyRequeueToken = broker.RegisterInterface(this);
return true;
@@ -298,6 +300,9 @@ bool GetSpawnClientSettingIdentifiers()
Task IAsyncModule.UnloadAsync(IComponentBroker broker, CancellationToken cancellationToken)
{
+ if (broker.UnregisterInterface(ref _iKoEarlyRequeueToken) != 0)
+ return Task.FromResult(false);
+
if (!broker.UnregisterAdvisor(ref _iMatchFocusAdvisorToken))
return Task.FromResult(false);
@@ -762,6 +767,25 @@ void PrintCommand(Player player, string command, string description)
#endregion
+ #region IKoEarlyRequeue members
+
+ void IKoEarlyRequeue.MarkPlayerKoEarlyRequeued(IMatchData matchData, string playerName)
+ {
+ if (matchData is not MatchData md)
+ return;
+
+ for (int i = 0; i < md.ParticipationList.Count; i++)
+ {
+ if (string.Equals(md.ParticipationList[i].PlayerName, playerName, StringComparison.OrdinalIgnoreCase))
+ {
+ md.ParticipationList[i] = md.ParticipationList[i] with { WasEarlyRequeued = true };
+ return;
+ }
+ }
+ }
+
+ #endregion
+
#region Callbacks
[ConfigHelp("SS.Matchmaking.TeamVersusMatch", "PublicPlayEnabled", ConfigScope.Arena, Default = false,
@@ -6708,9 +6732,10 @@ private async void EndMatch(MatchData matchData, MatchEndReason reason, Team? wi
int playerNameIndex = 0;
// Unset the players that are allowed to automatically requeue.
+ // Skip players that were early-requeued (already unset by KoRequeue and may now be playing in a new match).
foreach (PlayerParticipationRecord record in matchData.ParticipationList)
{
- if (!record.LeftWithoutSub)
+ if (!record.LeftWithoutSub && !record.WasEarlyRequeued)
{
playerNames[playerNameIndex++] = record.PlayerName;
}
@@ -7192,7 +7217,7 @@ public void Reset()
/// The name of the player.
/// Whether the player entered the match as a sub-in.
/// Whether the player left the match without having a replacement player ready sub-in.
- private record struct PlayerParticipationRecord(string PlayerName, bool WasSubIn, bool LeftWithoutSub);
+ private record struct PlayerParticipationRecord(string PlayerName, bool WasSubIn, bool LeftWithoutSub, bool WasEarlyRequeued = false);
private class Team : ITeam
{
diff --git a/src/Matchmaking/Modules/TeamVersusStats.cs b/src/Matchmaking/Modules/TeamVersusStats.cs
index d5939765..66a251b1 100644
--- a/src/Matchmaking/Modules/TeamVersusStats.cs
+++ b/src/Matchmaking/Modules/TeamVersusStats.cs
@@ -1435,7 +1435,29 @@ async Task ITeamVersusStatsBehavior.MatchEndedAsync(IMatchData matchData,
// Rate players using the OpenSkill model.
//
- // Prepare the rating calcuation inputs.
+ // Re-query ratings from DB before computing the update.
+ // This picks up any changes made by concurrent matches that ended while this match was running
+ // (e.g. a KO'd player who early-requeued and played another match before this one finished).
+ // Using the freshest available baseline ensures the optimistic-lock CAS check in SaveGameToDatabase
+ // will succeed without any in-memory propagation between matches.
+ // If the re-query fails, fall through using the cached start-of-match ratings.
+ if (_gameStatsRepository is not null && matchData.Configuration.GameTypeId is not null)
+ {
+ try
+ {
+ await _gameStatsRepository.GetPlayerOpenSkillRatingsAsync(
+ matchData.Configuration.GameTypeId.Value, matchStats.OpenSkillRatings);
+
+ // Re-apply decay based on the freshly-read LastUpdated timestamps.
+ AdjustOpenSkillRatingsForDecay(matchData.Configuration, matchStats.OpenSkillRatings, matchStats.EndTimestamp.Value);
+ }
+ catch
+ {
+ // DB unavailable; fall through with cached start-of-match ratings.
+ }
+ }
+
+ // Prepare the rating calculation inputs.
TimeSpan matchDuration = matchStats.EndTimestamp.Value - matchStats.StartTimestamp;
List teams = new(matchStats.Teams.Count);
List? ranks = null;
diff --git a/src/SubspaceServer/Zone/arenas/2v2pub/arena.conf b/src/SubspaceServer/Zone/arenas/2v2pub/arena.conf
index 7ec9c1d2..47d88697 100644
--- a/src/SubspaceServer/Zone/arenas/2v2pub/arena.conf
+++ b/src/SubspaceServer/Zone/arenas/2v2pub/arena.conf
@@ -14,6 +14,7 @@ AttachModules = \
SS.Matchmaking.Modules.MatchFocus \
SS.Matchmaking.Modules.TeamVersusStats \
SS.Matchmaking.Modules.RecklessPlayPenalty \
+ SS.Matchmaking.Modules.KoRequeue \
SS.Matchmaking.Modules.MatchLvz
[ Misc ]
@@ -31,3 +32,7 @@ PublicPlayEnabled = 0
[SS.Matchmaking.MatchFocus]
FilterKillPackets = 1
+
+[SS.Matchmaking.KoRequeue]
+Enabled = 0
+CooldownSeconds = 30
diff --git a/src/SubspaceServer/Zone/arenas/3v3pub/arena.conf b/src/SubspaceServer/Zone/arenas/3v3pub/arena.conf
index 520b255d..04da2d96 100644
--- a/src/SubspaceServer/Zone/arenas/3v3pub/arena.conf
+++ b/src/SubspaceServer/Zone/arenas/3v3pub/arena.conf
@@ -16,6 +16,7 @@ AttachModules = \
SS.Matchmaking.Modules.MatchFocus \
SS.Matchmaking.Modules.TeamVersusStats \
SS.Matchmaking.Modules.RecklessPlayPenalty \
+ SS.Matchmaking.Modules.KoRequeue \
SS.Matchmaking.Modules.MatchLvz
[ Misc ]
@@ -34,3 +35,7 @@ PublicPlayEnabled = 1
[SS.Matchmaking.MatchFocus]
FilterKillPackets = 1
+
+[SS.Matchmaking.KoRequeue]
+Enabled = 0
+CooldownSeconds = 30
diff --git a/src/SubspaceServer/Zone/arenas/4v4caps/arena.conf b/src/SubspaceServer/Zone/arenas/4v4caps/arena.conf
index a7555cff..8bffc49c 100644
--- a/src/SubspaceServer/Zone/arenas/4v4caps/arena.conf
+++ b/src/SubspaceServer/Zone/arenas/4v4caps/arena.conf
@@ -57,3 +57,13 @@ Freq400StartLocation = 710,360
[SS.Matchmaking.MatchFocus]
FilterKillPackets = 1
+
+[SS.Matchmaking.RecklessPlayPenalty]
+; Set to 1 to enable the reckless play penalty feature.
+Enabled = 0
+; KO within this many seconds of match start counts as reckless.
+ThresholdSeconds = 180
+; Penalty when KO'd right at the threshold boundary (minimum penalty).
+PenaltyMinimumSeconds = 120
+; Penalty when KO'd almost instantly after match start (maximum penalty).
+PenaltyMaximumSeconds = 600
diff --git a/src/SubspaceServer/Zone/arenas/4v4league/arena.conf b/src/SubspaceServer/Zone/arenas/4v4league/arena.conf
index 8fac060a..54ee151a 100644
--- a/src/SubspaceServer/Zone/arenas/4v4league/arena.conf
+++ b/src/SubspaceServer/Zone/arenas/4v4league/arena.conf
@@ -18,6 +18,7 @@ AttachModules = \
SS.Matchmaking.Modules.MatchFocus \
SS.Matchmaking.Modules.TeamVersusStats \
SS.Matchmaking.Modules.RecklessPlayPenalty \
+ SS.Matchmaking.Modules.KoRequeue \
SS.Matchmaking.Modules.MatchLvz
[ Misc ]
@@ -42,3 +43,7 @@ FilterKillPackets = 1
;; for commands: ?schedule, ?standings, ?results, ?roster, etc...
[SS.Matchmaking.League]
DefaultSeasonId = 3
+
+[SS.Matchmaking.KoRequeue]
+Enabled = 0
+CooldownSeconds = 30
diff --git a/src/SubspaceServer/Zone/arenas/4v4prac/arena.conf b/src/SubspaceServer/Zone/arenas/4v4prac/arena.conf
index 3d1e4a52..1dbc7133 100644
--- a/src/SubspaceServer/Zone/arenas/4v4prac/arena.conf
+++ b/src/SubspaceServer/Zone/arenas/4v4prac/arena.conf
@@ -16,6 +16,7 @@ AttachModules = \
SS.Matchmaking.Modules.MatchFocus \
SS.Matchmaking.Modules.TeamVersusStats \
SS.Matchmaking.Modules.RecklessPlayPenalty \
+ SS.Matchmaking.Modules.KoRequeue \
SS.Matchmaking.Modules.MatchLvz
[ Misc ]
@@ -44,3 +45,7 @@ ThresholdSeconds = 300
PenaltyMinimumSeconds = 120
; Penalty when KO'd almost instantly after match start (maximum penalty).
PenaltyMaximumSeconds = 600
+
+[SS.Matchmaking.KoRequeue]
+Enabled = 0
+CooldownSeconds = 30
diff --git a/src/SubspaceServer/Zone/conf/Modules.config b/src/SubspaceServer/Zone/conf/Modules.config
index 1b20caa2..8fbed207 100644
--- a/src/SubspaceServer/Zone/conf/Modules.config
+++ b/src/SubspaceServer/Zone/conf/Modules.config
@@ -202,6 +202,7 @@ For plug-in modules (e.g. custom modules that you build):
+