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:
| Moment | What players see | Modules |
|---|---|---|
| Start | Countdown title, match timer, scoreboard, the point marked in the world | HUD, Waypoints, World |
| Capture | A bar filling up while someone stands on the point | HUD |
| Kills | Kill feed entries, scoreboard updates | HUD |
| End | Victory title, the moment saved as a clip | HUD, 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.
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.
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.
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.
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.
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.
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.
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:
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").