Skip to Content
Modlock

A rotation mode

Run a lobby through one fight at a time, with a fair line and balanced sides.

A rotation mode runs a lobby through one fight at a time, such as a duel ring or a two against two pit, and the TypeScript library has a call for each part of one.

A player joins the line, waits in the stands, fights when their turn comes and goes to the back again, and each part of that has its calls:

PieceCall
Who is hereCast
Each player's record for the sessionStandings, Standing
Waiting in the standsholdBystander, playCamera, curve.hold
Who fights next, and on which sidenextMatch, afterMatch
An even share of heroesleastUsed
The resultsettle, ratingChange
The leaderboardleaderboard, Roster, rosterRows
Heroes left behind by a swapStrays

The rotation calls are TypeScript only. Each is pure: your mod keeps the line and the standings, and each call reads only what you pass it. Because the calls are pure, js/test/rotation.test.ts runs a whole session without the game.

The line and the standings

A player's standing is their record for the session: matches played, rating, wins, losses and current win streak. A Standings contains one Standing per Steam ID. Keep the line as an array of your own players, in arrival order, beside one Standings:

  • A player who leaves and rejoins keeps their record and place.
  • join creates the standing at startingRating, then raises matches played to the fewest anyone present has played, so arriving late or leaving to skip a turn never jumps the line.
  • A function passed to Standings adds your own fields to each new standing, such as matches played as each hero:
import { Cast, Standings } from 'modlock'

const standings = new Standings(() => ({ heroes: [0, 0] }))
let line: Entrant[] = []

const cast = new Cast({
  join: ({ player, name, steamId }) => {
    const standing = standings.join(
      steamId,
      line.map((each) => each.standing),
    )
    const entrant = { player, name, standing }
    line.push(entrant)
    return entrant
  },
  leave: (entrant) => {
    line = line.filter((each) => each !== entrant)
  },
})

The stands

A waiting player can sit in the stands with a hero that the fight does not use. holdBystander(entity) keeps it out of play, and holdBystander(entity, false) gives it back before the player fights. A held hero:

  • cannot move, shoot, cast or zoom;
  • cannot be targeted by either team's abilities, and bullets and melee pass over it;
  • cannot be pulled from its seat by displacement.

Teleport the hero to its seat facing the ring. Then aim playCamera's target, the point the camera turns around, at the ring's center with a long distance. The player circles that view with the mouse. Each param snaps back when its curve ends. Hold each curve longer than any wait, and clear the context when the player leaves the stands:

import {
  type Angles,
  curve,
  holdBystander,
  playCamera,
  type Player,
  type Vector,
} from 'modlock'

const context = 'arena.stands'

function seat(
  player: Player,
  pawn: number,
  position: Vector,
  facing: Angles,
): boolean {
  player.teleport(position, facing)
  holdBystander(pawn)
  player.clearCamera(context)
  const move = playCamera(player, {
    context,
    priority: 4,
    tracks: {
      target: curve.hold({ x: 0, y: 0, z: 60 }, 3600),
      distance: curve.hold(1400, 3600),
      verticalOffset: curve.hold(0, 3600),
      horizontalOffset: curve.hold(0, 3600),
    },
  })
  return move !== undefined
}

function unseat(player: Player, pawn: number): void {
  holdBystander(pawn, false)
  player.clearCamera(context)
}

playCamera returns nothing while the player has no hero. Mark the seat taken only once seat returns true, and try again each frame. A bystander can still die from a fall or world damage, so respawn it and seat it again.

The next match

When it is time for the next fight, nextMatch(line, size, standing) picks who fights, in two steps:

  1. It takes the 2 * size players who have played fewest. Among equals, the one earlier in line goes first.
  2. It deals them to the sides by rating in a snake (first, second, second, first), which keeps the sides' ratings close. With two per side, the best pairs with the worst against the middle two.

While too few players wait, nextMatch returns nothing, and your HUD can show that as waiting for players.

const sides = nextMatch(line, size, (each) => each.standing)
if (sides) {
  for (const fighter of sides.flat()) {
    fighter.standing.played++
  }
}

Count the match as played when it is seated, not when it ends, so a fighter who leaves mid-match still goes to the back of the order.

An even share of heroes

When the mode picks each fighter's hero from a short list, count each player's matches per hero. leastUsed(counts, prefer) returns the one played least, or prefer on a tie, so everyone plays every hero about equally and two fighters can start on different heroes by preferring different ones.

const choice = leastUsed(fighter.standing.heroes, side === 0 ? 0 : 1)
fighter.standing.heroes[choice]++

The result

When the fight ends, these calls record it and move the line on:

CallDoes
settle(winners, losers)Moves both sides' ratings by the Elo step between their averages, larger for an upset; counts wins, losses and streaks; returns the change, for a banner such as +16.
ratingChangeThe same step alone.
step optionReplaces ratingStep (32) for matches that should count more or less.
afterMatch(line, fought)Sends the fighters to the back of the line, everyone else keeping their order.
const change = settle(
  winners.map((each) => each.standing),
  losers.map((each) => each.standing),
)
line = afterMatch(line, [...winners, ...losers])

The leaderboard

leaderboard(players, standing, rows, viewer) ranks by wins, then rating, and returns the first rows, keeping the viewer on the last row if they would fall off. Roster draws each row's hero, avatar and a line of activity:

const rows = leaderboard(line, (each) => each.standing, 8, viewer)
show(
  viewer.player,
  Roster({
    rows: rows.map((each) => ({
      name: each.name,
      self: each === viewer,
      hero: each.hero,
      steamId: each.steamId,
      activity: `${each.standing.wins}-${each.standing.losses} · ${each.standing.rating}`,
    })),
  }),
)

Heroes left behind

Swapping a player's hero, or moving them to the spectators and back, can leave the old hero with no player controlling it. The game still revives that hero on its own timer. A hero belongs to a player while the player's controller points to it, so a player who joins or spectates keeps their hero. Once a hero has belonged to nobody for strayGraceSeconds, Strays kills it once and holds off its respawn for good. The sweep checks once a second, with one entity listing and two field reads per hero. Keep one Strays and call sweep(now) each frame:

import { onFrame, Strays } from 'modlock'

const strays = new Strays()
onFrame(({ timeSeconds }) => strays.sweep(timeSeconds))

The sweep leaves a stray's abilities in place, because killing a live hero's ability entities has crashed the server.

On this page