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:
| Piece | Call |
|---|---|
| Who is here | Cast |
| Each player's record for the session | Standings, Standing |
| Waiting in the stands | holdBystander, playCamera, curve.hold |
| Who fights next, and on which side | nextMatch, afterMatch |
| An even share of heroes | leastUsed |
| The result | settle, ratingChange |
| The leaderboard | leaderboard, Roster, rosterRows |
| Heroes left behind by a swap | Strays |
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.
joincreates the standing atstartingRating, 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
Standingsadds 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:
- It takes the
2 * sizeplayers who have played fewest. Among equals, the one earlier in line goes first. - 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:
| Call | Does |
|---|---|
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. |
ratingChange | The same step alone. |
step option | Replaces 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.