Skip to Content
Modlock

Tutorial

Build a small brawl with a countdown, a score, a HUD and a sign.

This tutorial will start with the template created by modlock new and show you how to build a small brawl with a countdown, score, HUD, and a sign. You can run modlock dev to start the server and the game, and it will hot reload as you make changes, no restart required. The examples build the same ideas into complete game modes you can play.

Put a step's import lines at the top of the main file and the rest of its code at the end. A step imports only the names it adds, so keep the imports from earlier steps.

1. Answer a command

Start by making the mod answer /roll with a roll of the dice.

import { toast } from 'modlock'

command('roll', (player, args) => {
  const sides = Number(args) || 6
  const roll = 1 + Math.floor(Math.random() * sides)
  toast(player, `You rolled ${roll} out of ${sides}.`)
})
local ui = require("@modlock/ui")

modlock.command("roll", function(player, args)
	local sides = math.max(1, math.floor(tonumber(args) or 6))
	local roll = math.random(1, sides)
	ui.toast(player, `You rolled {roll} out of {sides}.`)
end)
import random

from modlock.ui import toast


@modlock.command("roll")
def roll(player: modlock.Player, args: str) -> None:
    sides = int(args) if args.isdigit() and int(args) > 0 else 6
    toast(player, f"You rolled {random.randint(1, sides)} out of {sides}.")
import (
	"math/rand/v2"
	"strconv"

	"github.com/paralin/modlock/mod/ui"
)

func init() {
	mod.Command("roll", func(p mod.Player, args string) {
		sides, err := strconv.Atoi(args)
		if err != nil || sides < 1 {
			sides = 6
		}
		roll := 1 + rand.IntN(sides)
		ui.Toast(p, "You rolled "+strconv.Itoa(roll)+" out of "+strconv.Itoa(sides)+".")
	})
}

Save, then type /roll 20 in the game's chat. A notice slides in at the lower left with your roll, and it fades after four seconds.

command registers the handler, and the server calls it whenever a player types /roll. The handler gets that player and the text after the name, here 20. When that text is not a number, the sample rolls six sides. toast shows the notice, and is one of the interface calls.

2. Read the hero

Now add a second command, /where, that tells you where your hero is standing and how much health it has.

command('where', (player) => {
  const pawn = player.pawn()
  if (pawn === undefined || pawn.health <= 0) {
    toast(player, 'You have no living hero.')
    return
  }
  const { x, y } = pawn.position
  toast(
    player,
    `You stand at ${x.toFixed(0)}, ${y.toFixed(0)} with ${pawn.health} health.`,
  )
})
modlock.command("where", function(player)
	local pawn = player:pawn()
	if pawn == nil or pawn.health <= 0 then
		player:chat("You have no living hero.")
		return
	end
	local x, y = math.round(pawn.position.x), math.round(pawn.position.y)
	player:chat(`You stand at {x}, {y} with {pawn.health} health.`)
end)
@modlock.command("where")
def where(player: modlock.Player, args: str) -> None:
    pawn = player.pawn()
    if pawn is None or pawn.health <= 0:
        player.chat("You have no living hero.")
        return
    x, y = pawn.position.x, pawn.position.y
    player.chat(f"You stand at {x:.0f}, {y:.0f} with {pawn.health} health.")
import "fmt"

func init() {
	mod.Command("where", func(p mod.Player, args string) {
		pawn, err := p.Pawn()
		if err != nil || pawn == nil || pawn.GetHealth() <= 0 {
			_ = p.Chat("You have no living hero.")
			return
		}
		at := pawn.GetPosition()
		_ = p.Chat(fmt.Sprintf("You stand at %.0f, %.0f with %d health.",
			at.GetX(), at.GetY(), pawn.GetHealth()))
	})
}

Type /where once your hero has spawned. The TypeScript sample answers in a notice, as /roll does; the Luau, Python and Go samples answer with a line in your chat. Before your hero spawns, or while it is dead, the answer is "You have no living hero."

A player's pawn is their hero as it is in the current frame: its health, team, position, aim direction and souls. pawn() returns nothing until the hero has spawned, and a hero whose health is zero is dead. The sample checks both before it reads the position.

3. Count down with a timer

With commands working, you can give the brawl a start: /start will freeze every hero for five seconds and then let them fight.

import { after, players } from 'modlock'

let live = false

command('start', () => {
  for (const { player } of players()) {
    player.freeze(true)
  }
  after(5, () => {
    live = true
    for (const { player } of players()) {
      player.freeze(false)
      player.announce('Fight!', 'The round is live.')
    }
  })
})
local live = false

modlock.command("start", function()
	for _, connection in modlock.players() do
		connection.player:freeze(true)
	end
	modlock.after(5, function()
		live = true
		for _, connection in modlock.players() do
			connection.player:freeze(false)
			connection.player:announce("Fight!", "The round is live.")
		end
	end)
end)
live = False


@modlock.command("start")
def countdown(player: modlock.Player, args: str) -> None:
    for connection in modlock.players():
        connection.player.freeze(True)

    def go() -> None:
        global live
        live = True
        for connection in modlock.players():
            connection.player.freeze(False)
            connection.player.announce("Fight!", "The round is live.")

    modlock.after(5, go)
var live bool

func init() {
	mod.Command("start", func(mod.Player, string) {
		connections, _ := mod.Players()
		for _, c := range connections {
			_ = mod.PlayerOf(c.GetPlayer()).Freeze(nil)
		}
		mod.After(5*time.Second, func() {
			live = true
			released := false
			connections, _ := mod.Players()
			for _, c := range connections {
				p := mod.PlayerOf(c.GetPlayer())
				_ = p.Freeze(&released)
				_ = p.Announce("Fight!", "The round is live.")
			}
		})
	})
}

Type /start. Your hero is held for five seconds and takes no input. Then it is released, and the game shows an announcement titled "Fight!" that says "The round is live."

players() lists everyone connected, bots included, and freeze holds each one's hero. after calls its function once, five seconds of game time later. The game clock stops while the game is paused, so a paused round also holds its timers. When the timer fires, the sample releases every hero, announces the round and sets live, which the next step reads. Timers explains repeating timers and sequences.

4. Decide the fights

The round has a start now, and live says whether it has begun. Use it to block every hit before /start and to count each defeat after.

import { onDamage, onKill, type Player } from 'modlock'

const defeats = new Map<Player, number>()

// Nobody gets hurt until the round is live.
onDamage(() => (live ? undefined : { block: true }))

// A hero's defeat scores for the player who landed the killing hit.
onKill((kill) => {
  const scorer = kill.attackerPlayer
  const victim = kill.victimPlayer
  if (victim.slot < 0 || scorer.slot < 0 || scorer === victim) {
    return
  }
  defeats.set(scorer, (defeats.get(scorer) ?? 0) + 1)
})
local defeats: { [modlock.Player]: number } = {}

-- Nobody gets hurt until the round is live.
modlock.onDamage(function(hit)
	if live then
		return nil
	end
	return { block = true }
end)

-- A hero's defeat scores for the player who landed the killing hit.
modlock.onKill(function(kill)
	local scorer, victim = kill.attackerPlayer, kill.victimPlayer
	if victim.slot < 0 or scorer.slot < 0 or scorer == victim then
		return
	end
	defeats[scorer] = (defeats[scorer] or 0) + 1
end)
defeats: dict[modlock.Player, int] = {}


# Nobody gets hurt until the round is live.
@modlock.on_damage
def guard(hit: modlock.DamageEvent) -> modlock.DamageResult | None:
    return None if live else modlock.DamageResult(block=True)


# A hero's defeat scores for the player who landed the killing hit.
@modlock.on_kill
def scored(kill: modlock.KillEvent) -> None:
    scorer, victim = kill.attacker_player, kill.victim_player
    if victim.slot < 0 or scorer.slot < 0 or scorer is victim:
        return
    defeats[scorer] = defeats.get(scorer, 0) + 1
var defeats = map[mod.Player]int{}

func init() {
	// Nobody gets hurt until the round is live.
	mod.OnDamage(func(hit *mod.DamageEvent) *mod.DamageResult {
		if live {
			return nil
		}
		block := true
		return &mod.DamageResult{Block: &block}
	})

	// A hero's defeat scores for the player who landed the killing hit.
	mod.OnKill(func(kill *mod.KillEvent) {
		scorer, victim := mod.PlayerOf(kill.GetAttackerPlayer()), mod.PlayerOf(kill.GetVictimPlayer())
		if victim.Slot() < 0 || scorer.Slot() < 0 || scorer == victim {
			return
		}
		defeats[scorer]++
	})
}

To try it you need another hero to hit, such as a friend's or a bot's. Before you type /start, your hits on it do no damage. Once the announcement shows, they land, and each hero you defeat adds one to your count in defeats. Nothing shows that count yet; the next step draws it.

The damage event comes before the game applies each hit, so the sample can block it while live is false. The kill event comes after each hit that kills a hero or a unit, before the next frame. It names the player whose hero died and the player whose hero landed the hit. A slot of -1 means a unit, or a hazard for the attacker. A hero that killed itself is its own attacker. The sample scores a kill only when both are players and the attacker is someone else.

This map keys scores by player. When someone leaves and another person takes their slot, the newcomer is a new key and starts at zero. The map still holds the leaver's score, so in a mode where players come and go, delete a player's entry when they leave. The mod hears each leave before the player list that drops them, so the leave handler still finds the entry.

Each connection also has roles, which tell you which players build and test the mod.

5. Show a HUD

The mod counts defeats now but keeps them to itself, so next you'll put each player's count in the top right corner of their screen. Write the HUD in its own module beside the main one: hud.tsx, hud.luau or hud.py, or in Go a hud.go in the same package:

import { type Player, show } from 'modlock'

export function drawScore(player: Player, defeats: number): void {
  show(player, <Score defeats={defeats} />)
}

function Score(props: { defeats: number }) {
  return (
    <panel
      style={{
        horizontalAlign: 'right',
        verticalAlign: 'top',
        margin: [80, 24],
        padding: 10,
        background: '#101820d0',
        borderRadius: 6,
      }}
    >
      <label style={{ fontSize: 24, bold: true }}>
        {props.defeats} defeats
      </label>
    </panel>
  )
}
local modlock = require("@modlock")
local ui = require("@modlock/ui")

local hud = {}

function hud.drawScore(player: modlock.Player, defeats: number)
	ui.show(player, ui.panel {
		style = {
			horizontalAlign = "right",
			verticalAlign = "top",
			margin = { 80, 24 },
			padding = 10,
			background = "#101820d0",
			borderRadius = 6,
		},
		ui.label { style = { fontSize = 24, bold = true }, `{defeats} defeats` },
	})
end

return hud
import modlock
from modlock.ui import label, panel, show


def draw_score(player: modlock.Player, defeats: int) -> None:
    show(player, panel(style={
        "horizontal_align": "right",
        "vertical_align": "top",
        "margin": (80, 24),
        "padding": 10,
        "background": "#101820d0",
        "border_radius": 6,
    })[
        label(style={"font_size": 24, "bold": True})[f"{defeats} defeats"],
    ])
package main

import (
	"strconv"

	"github.com/paralin/modlock/mod"
	"github.com/paralin/modlock/mod/ui"
)

func drawScore(p mod.Player, defeats int) {
	ui.Show(p, ui.Panel{
		Style: ui.Style{
			HorizontalAlign: ui.Right,
			VerticalAlign:   ui.Top,
			Margin:          ui.Edges{80, 24},
			Padding:         ui.Edges{10},
			Background:      "#101820d0",
			BorderRadius:    6,
		},
		Children: ui.Label{
			Style: ui.Style{FontSize: 24, Bold: true},
			Text:  strconv.Itoa(defeats) + " defeats",
		},
	})
}

Draw the HUD from the main module on every frame:

import { onFrame } from 'modlock'
import { drawScore } from './hud'

onFrame(() => {
  for (const { player } of players()) {
    drawScore(player, defeats.get(player) ?? 0)
  }
})
local hud = require("./hud")

modlock.onFrame(function()
	for _, connection in modlock.players() do
		hud.drawScore(connection.player, defeats[connection.player] or 0)
	end
end)
import hud


@modlock.on_frame
def draw(frame: modlock.FrameEvent) -> None:
    for connection in modlock.players():
        hud.draw_score(connection.player, defeats.get(connection.player, 0))
func init() {
	mod.OnFrame(func(*mod.FrameEvent) {
		connections, _ := mod.Players()
		for _, c := range connections {
			p := mod.PlayerOf(c.GetPlayer())
			drawScore(p, defeats[p])
		}
	})
}

Each player now sees a panel in the top right corner that reads "0 defeats". The number goes up each time they defeat a hero after /start.

An interface is a tree of elements. In TypeScript you write it as JSX in a .tsx module, and in the other languages you build it with calls to the ui library. drawScore builds a panel with one label and passes it to show. The frame handler redraws every player's HUD from defeats on each frame. show compares the tree with the one it sent that player last and sends only what changed, so a redraw on every frame costs little. When a player presses a button in a tree, the mod calls its onPress with that player.

Interfaces lists every element and style.

6. Place things in the world

So far everything the mod shows is on the player's screen. Now you'll put something in the arena itself: /sign places a line of text above your hero.

import { createText, onWorld, type WorldObject } from 'modlock'

let sign: WorldObject | undefined

// A new world has removed the sign.
onWorld(() => {
  sign = undefined
})

command('sign', (player, args) => {
  const pawn = player.pawn()
  if (pawn === undefined) {
    return
  }
  const { x, y, z } = pawn.position
  sign?.remove()
  sign = createText({
    text: args || 'Hello!',
    position: { x, y, z: z + 120 },
    faceCamera: true,
  })
})
local sign: modlock.WorldObject? = nil

-- A new world has removed the sign.
modlock.onWorld(function()
	sign = nil
end)

modlock.command("sign", function(player, args)
	local pawn = player:pawn()
	if pawn == nil then
		return
	end
	if sign then
		sign:remove()
	end
	local at = pawn.position
	sign = modlock.createText({
		text = if args ~= "" then args else "Hello!",
		position = { x = at.x, y = at.y, z = at.z + 120 },
		faceCamera = true,
	})
end)
sign: modlock.WorldObject | None = None


# A new world has removed the sign.
@modlock.on_world
def forget(map_name: str) -> None:
    global sign
    sign = None


@modlock.command("sign")
def place(player: modlock.Player, args: str) -> None:
    global sign
    pawn = player.pawn()
    if pawn is None:
        return
    if sign is not None:
        sign.remove()
    at = pawn.position
    sign = modlock.create_text(
        modlock.TextOptions(
            text=args or "Hello!",
            position=modlock.Vector(at.x, at.y, at.z + 120),
            face_camera=True,
        )
    )
var sign *mod.WorldObject

func init() {
	// A new world has removed the sign.
	mod.OnWorld(func(string) { sign = nil })

	mod.Command("sign", func(p mod.Player, args string) {
		pawn, err := p.Pawn()
		if err != nil || pawn == nil {
			return
		}
		if sign != nil {
			_ = sign.Remove()
		}
		if args == "" {
			args = "Hello!"
		}
		at, faceCamera := pawn.GetPosition(), true
		text, err := mod.CreateText(&mod.TextOptions{
			Text:       args,
			Position:   &mod.Vector{X: at.GetX(), Y: at.GetY(), Z: at.GetZ() + 120},
			FaceCamera: &faceCamera,
		})
		if err == nil {
			sign = &text
		}
	})
}

Type /sign Meet here once your hero has spawned. The words float in white 120 units above where you stood and turn to face whoever looks at them. Type /sign again somewhere else, and the old sign goes as the new one appears.

createText places the text and returns an object you can move, change or remove; the sample keeps it in sign so that the next /sign can remove it. The mod can place props and effects the same way, and loading a new map removes all of them. The world event fires as each world loads, so the sample drops its reference there. Models and effects need one more step: precache them when the mod starts, so the game loads them with the world. The world explains models, effects and fog.

7. Let players choose, and keep score

The score sits in the top right for everyone. Next, let each player choose its corner, and keep each player's defeats as a running total. A setting is a choice each player makes once and keeps between matches. A metric is a total for each player that the mod adds to. Declare both in mod.json, under settings and metrics:

"settings": [
  {
    "key": "corner",
    "label": "Score corner",
    "kind": "KIND_CHOICE",
    "choices": [
      { "value": "right", "label": "Top right" },
      { "value": "left", "label": "Top left" }
    ]
  }
],
"metrics": [{ "name": "brawl.defeats", "kind": "KIND_COUNT" }]

Read the player's setting in drawScore and align the panel with it. Add to the metric in the kill handler once the kill scores:

const corner = player.setting('corner') === 'left' ? 'left' : 'right'
scorer.addMetric('brawl.defeats', 1)
local corner = if player:setting("corner") == "left" then "left" else "right"
scorer:addMetric("brawl.defeats", 1)
corner = "left" if player.setting("corner") == "left" else "right"
scorer.add_metric("brawl.defeats", 1)
var corner ui.Align = ui.Right
if choice, _ := p.Setting("corner"); choice == "left" {
	corner = ui.Left
}
_ = scorer.AddMetric("brawl.defeats", 1, nil)

A player who chose "Top left" now sees the score in the top left corner; everyone else keeps it in the top right, since the sample moves it only for left. setting returns the player's choice, or the setting's default before they choose. Each defeat now also adds one to the player's brawl.defeats total.

A board ranks players against each other, such as by their fastest clear of each course. Settings, metrics and boards shows how to declare a board and report scores to it.

8. Split it into modules

The main file now holds commands, events and the round's state side by side. As the mode grows, give each concern its own module. Keep main.ts a short list that registers the handlers, and keep the game's state in one object that the handlers call into. modlock build bundles and type-checks every module that main.ts imports.

import { command, onDamage, onFrame, onStart, onWorld } from 'modlock'

import { drawScoreboards } from './hud'
import { Match } from './match'

const match = new Match()

onStart(() => match.start())
onWorld(() => match.world())
onDamage((hit) => match.damage(hit))
onFrame((frame) => {
  match.frame(frame.timeSeconds)
  drawScoreboards(match)
})
command('ready', (player) => match.ready(player))

examples/arena is laid out this way.

Keep each handler quick. An event that takes more than 500 ms of processor time shows the players a warning. When one takes more than five seconds, the server stops the mod until a player types /restartmod or a reload replaces it. Slow and stopped mods explains both limits.

9. Test a whole round

Last, test a whole round without a second person: your mod can add bots, play the round against them and log PASS or FAIL, as Testing shows. When the mode plays well, publish it.

On this page