.dev

Recipes3 min read

A full match HUD

Build a capture-the-point match from first whistle to victory screen, feature by feature.

By the end of this page your server runs a capture-the-point match that feels like it was built into the game. Players get a countdown title when it starts, a live scoreboard, a beam and a waypoint on the point they need to hold, a progress bar while they capture, a kill feed, and a victory screen. The winning moment is even saved as a clip for anyone with the replay buffer on.

None of it uses armor stands, scoreboard tricks or a resource pack. It is about 120 lines of plugin code, and every piece degrades gracefully: players without Teho still get a working match, they just do not get the extras.

The shape of a match

A match goes through four moments, and each one maps onto a handful of Teho calls:

MomentWhat players seeModules
StartCountdown title, match timer, scoreboard, the point marked in the worldHUD, Waypoints, World
CaptureA bar filling up while someone stands on the pointHUD
KillsKill feed entries, scoreboard updatesHUD
EndVictory title, the moment saved as a clipHUD, Clips

We will build them in that order. Every snippet lives in one class, Match, that your game logic calls into.

Start: tell everyone the match is on

When the match starts, send each Teho player the whole HUD at once. Timers tick on the client, so you send the countdown once and never touch it again.

Java
public final class Match {
    private static final int POINT_X = 0, POINT_Y = 64, POINT_Z = 0;
    private final Map<UUID, Integer> kills = new HashMap<>();
    private int red, blue;

    public void start(Collection<? extends Player> players) {
        for (Player player : players) {
            BukkitTeho.player(player).ifPresent(teho -> {
                teho.hud().title(Title.of("Capture the point")
                        .subtitle("First team to 3 wins")
                        .animation(Title.Animation.POP)
                        .times(200, 2500, 400)
                        .build());
                teho.hud().timer(Timer.countdown("match", "Match ends", 10 * 60_000).color(0xFFC24D));
                teho.hud().sidebar(scoreboard());
                markThePoint(teho, player.getWorld());
            });
        }
    }
}

Players who are not on Teho are simply skipped by ifPresent, so the rest of your match code does not need to care.

Build a scoreboard worth looking at

The Teho sidebar replaces the vanilla one, so you are not stuck with 15 grey lines. Rows have a label, a value, an optional item icon and an optional progress bar.

Java
private Sidebar scoreboard() {
    return Sidebar.replace("Capture")
            .accent(0xF472B6)
            .row(Sidebar.Row.of("red", "Red").value(red + " / 3").color(0xFF5555))
            .row(Sidebar.Row.of("blue", "Blue").value(blue + " / 3").color(0x4DA9FF))
            .row(Sidebar.Row.of("point", "Point").value("Neutral").item("white_banner"))
            .footer("play.skyline.gg")
            .build();
}

Later, when only one row changes, send an update instead of the whole board. The client patches just that row, which is both cheaper and smoother.

Java
private void updatePoint(TehoPlayer teho, String owner, int color) {
    teho.hud().sidebar(Sidebar.update()
            .row(Sidebar.Row.of("point", "Point").value(owner).color(color).item("white_banner"))
            .build());
}

Mark the point so nobody has to ask where it is

A waypoint tells players how far away the point is from anywhere on the map, and a beam makes it visible over hills and through fog. Both are drawn by the client, so they cost your server nothing.

Java
private void markThePoint(TehoPlayer teho, World world) {
    String dimension = BukkitTeho.dimensionOf(world);
    teho.waypoints().set(Waypoint.builder("point")
            .name("The point")
            .at(POINT_X, POINT_Y, POINT_Z)
            .dimension(dimension)
            .color(0xFFC24D)
            .focus(true)
            .build());
    teho.world().beam(Beam.at("point", POINT_X + 0.5, POINT_Y, POINT_Z + 0.5)
            .color(0xFFC24D)
            .height(200)
            .pulse(true)
            .dimension(dimension)
            .build());
}

focus(true) makes it the waypoint the player's HUD readout tracks, so the distance is always on screen.

Show the capture filling up

While a team stands on the point, call this every second with the current progress. Sending the same bar id again updates it in place.

Java
public void capturing(Player player, String team, double progress) {
    BukkitTeho.player(player).ifPresent(teho -> teho.hud().bar(
            Bar.builder("capture", team + " is capturing", progress)
                    .color(team.equals("Red") ? 0xFF5555 : 0x4DA9FF)
                    .style(Bar.Style.SEGMENTED)
                    .icon("white_banner")
                    .ttl(2000)
                    .build()));
}

The two-second ttl means that when capturing stops, the bar disappears by itself. You never have to remember to remove it.

Feed the kills

On every kill, add an entry to the kill feed for everyone and bump the killer's count.

Java
public void kill(Player killer, Player victim, String weapon) {
    kills.merge(killer.getUniqueId(), 1, Integer::sum);
    for (Player player : Bukkit.getOnlinePlayers()) {
        BukkitTeho.player(player).ifPresent(teho -> teho.hud().kill(
                killer.getName(), killer.getUniqueId(),
                victim.getName(), victim.getUniqueId(),
                weapon, 0xFF5555));
    }
}

End: make the win feel like one

When a team reaches three captures, everyone gets a big title, and the last few seconds are saved as a highlight for players who turned the replay buffer on.

Java
public void end(String winner, int color) {
    for (Player player : Bukkit.getOnlinePlayers()) {
        BukkitTeho.player(player).ifPresent(teho -> {
            teho.hud().removeTimer("match");
            teho.hud().title(Title.of(winner + " wins")
                    .subtitle("GG, back to the lobby in 10 seconds")
                    .animation(Title.Animation.POP)
                    .color(color)
                    .scale(1.6)
                    .build());
            teho.clips().mark("win-" + System.currentTimeMillis(), winner + " wins", 10_000, 3_000);
        });
    }
}

Clean up between matches

Everything you sent is remembered per player and re-sent if their client reconnects, so clear it when the match is over. One call wipes everything your server applied:

Java
BukkitTeho.player(player).ifPresent(TehoPlayer::reset);

If you would rather keep things like the tab list or keybinds across matches, remove just the match pieces instead: removeTimer, clearSidebar, waypoints().remove("point") and world().removeBeams("point").

Where to go from here