Chapter 9, Achievements

A toast pops, an icon un-greys, and a little tree of accomplishment grows on a page that's all yours.

Achievements are the cheapest dopamine in game design and the easiest thing to add with RetroAPI. They're really two halves: a definition (an icon, a position, a parent, two lines of text) and a grant (the moment in gameplay where you award it). We'll define three, give them a home page, and then wire each one to a different gameplay trigger so you can see the whole spectrum of ways to award them.

Defining them

Everything lives in one small class, called from init() after your blocks and items exist, because an achievement's icon is often a block or item you registered earlier.

src/main/java/com/example/example_mod/ExampleAchievements.java
package com.example.example_mod;

import com.periut.retroapi.achievement.AchievementPage;
import com.periut.retroapi.achievement.RetroAchievements;

import net.minecraft.achievement.Achievement;
import net.minecraft.item.Item;

public final class ExampleAchievements {
	private ExampleAchievements() {}

	/** Granted on join (ExamplePlayerSetup); lives on the VANILLA page. */
	public static Achievement GETTING_STARTED;
	/** Granted on first jump (LivingEntityJumpMixin); root of the mod's own page. */
	public static Achievement JUMP_FOR_JOY;
	/** Granted by walking into the portal (ExamplePortalBlock); child of JUMP_FOR_JOY. */
	public static Achievement DIMENSIONAL_TRAVELER;

	public static void register() {
		// A root achievement (parent == null). Icon is an Item here; Block and
		// ItemStack overloads exist too. The two ints are the column/row position
		// on the achievements screen. Not added to a page below, so it appears on
		// the vanilla page.
		GETTING_STARTED = RetroAchievements.register(
			ExampleMod.id("getting_started"),
			"example_mod.getting_started",
			-2, -2,
			ExampleMod.SUSPICIOUS_SUBSTANCE,
			null
		);

		// Root of our own page.
		JUMP_FOR_JOY = RetroAchievements.register(
			ExampleMod.id("jump_for_joy"),
			"example_mod.jump_for_joy",
			0, 0,
			Item.FEATHER,
			null
		);

		// A child achievement: pass the parent as the last argument and the
		// achievements screen draws the connecting line from it.
		DIMENSIONAL_TRAVELER = RetroAchievements.register(
			ExampleMod.id("dimensional_traveler"),
			"example_mod.dimensional_traveler",
			2, 1,
			ExampleMod.EXAMPLE_PORTAL,
			JUMP_FOR_JOY
		);

		// Give the mod its own page on the achievements screen (the < > buttons).
		// Page title lang key: gui.retroapi.achievementPage.example_mod.example_page
		AchievementPage page = new AchievementPage(ExampleMod.id("example_page"));
		page.addAchievements(JUMP_FOR_JOY, DIMENSIONAL_TRAVELER);
	}
}

Reading register(...)

The signature is register(id, name, column, row, icon, parent). Each argument earns its place:

ArgumentWhat it is
ida NamespacedIdentifier, RetroAPI's stable handle for this achievement
namethe lang-key stem; "example_mod.getting_started" becomes the title and description keys
column, rowwhere the icon sits on the achievements screen, a coordinate grid, not a list
icona Block, an Item, or an ItemStack, three overloads, pick whichever you have
parentthe achievement this one branches from, or null for a root

The icon overloads are why this call reads cleanly: GETTING_STARTED uses the Suspicious Substance, JUMP_FOR_JOY uses a vanilla feather, and DIMENSIONAL_TRAVELER uses the portal block, and each just passes the object it has, no wrapping required. Pass null for the parent and the achievement is a root, the start of a branch. Pass an existing achievement and the screen draws a connecting line from parent to child, building the familiar tree.

There's also a family of register(int fixedId, …) overloads that take an explicit numeric id as the first argument, for the three icon types. You'd reach for these when you need a StationAPI-stable id, a fixed number that never shifts as you add or remove achievements. The auto-allocating overloads shown above are the right default; the fixed-id ones are there when stability across versions matters.

The words

Each achievement needs two lang lines: a title and a description, keyed off the name stem. The title key is achievement.<name> and the description is achievement.<name>.desc. Here's the achievements section of the lang file:

src/main/resources/assets/example_mod/lang/en_US.lang
# Achievements: achievement.<name> is the title, achievement.<name>.desc the
# description, where <name> is the string passed to RetroAchievements.register.
achievement.example_mod.getting_started=Getting Started
achievement.example_mod.getting_started.desc=Join a world with the example mod installed.
achievement.example_mod.jump_for_joy=Jump for Joy
achievement.example_mod.jump_for_joy.desc=Jump!
achievement.example_mod.dimensional_traveler=Dimensional Traveler
achievement.example_mod.dimensional_traveler.desc=Step through the example portal.

# Title of the mod's own achievement page (the < > buttons on the achievements screen).
gui.retroapi.achievementPage.example_mod.example_page=Example Mod

That last line names the page, with the key gui.retroapi.achievementPage.example_mod.example_page, built from the page id you'll see in a moment. (As Chapter 3 covered, anything you forget to translate falls back to an auto-generated name, so a missing line is a cosmetic blemish, never a crash.)

Pages

The achievements screen in Beta 1.7.3 is a scrollable map, and mods can add their own maps reachable with the < > arrows at the top. There's exactly one rule that decides where an achievement lands, and it's worth saying clearly:

An achievement you do not add to a page appears on the vanilla page, mixed in with Minecraft's own. Add it to a custom AchievementPage and it lives there instead.

That's why GETTING_STARTED shows up alongside vanilla's achievements, it's never added to a page, on purpose. It's the "you installed the mod, welcome" badge, and the vanilla page is exactly where a new player will see it first.

The other two get a home of their own. Creating the page auto-registers it; you then claim achievements onto it:

src/main/java/com/example/example_mod/ExampleAchievements.java, register()
AchievementPage page = new AchievementPage(ExampleMod.id("example_page"));
page.addAchievements(JUMP_FOR_JOY, DIMENSIONAL_TRAVELER);

Constructing new AchievementPage(id) registers it for you, no separate "add page" call, and the < > buttons appear on the screen the moment any custom page exists. Then page.addAchievements(…) claims them. The result is a tidy little tree: JUMP_FOR_JOY sits at the root (the feather), and DIMENSIONAL_TRAVELER hangs off it as a child, drawn with a connecting line, because we passed JUMP_FOR_JOY as its parent back in the definition. Two achievements, one branch, one page titled "Example Mod."

Granting from gameplay

Defining an achievement makes it exist, greyed out, waiting. Granting it is the gameplay moment that lights it up. The call is always the same one line, RetroAchievements.grant(achievement, player), but where you put it is the interesting part. The showcase grants its three achievements from three completely different places.

1. On join, straight from setup code

The simplest grant is a plain function call at the right moment. When a player joins, the per-player setup hands out a starter kit and, while it's at it, grants the welcome achievement:

src/main/java/com/example/example_mod/ExamplePlayerSetup.java, run()
// Grant the join achievement, the toast pops and it un-greys on the screen.
RetroAchievements.grant(ExampleAchievements.GETTING_STARTED, player);

2. On a portal touch, from a block's collision handler

The portal block grants DIMENSIONAL_TRAVELER the instant the player steps into it, right inside onEntityCollision. Note the guard, !world.isRemote, which keeps the grant on the authoritative side:

src/main/java/com/example/example_mod/ExamplePortalBlock.java, onEntityCollision()
// A gameplay-triggered achievement grant (see ExampleAchievements).
if (!world.isRemote) {
	RetroAchievements.grant(ExampleAchievements.DIMENSIONAL_TRAVELER, player);
}

This is the portal we'll build in full in Chapter 10, but it's a nice glimpse of how an achievement grant slots naturally into any block behavior you write.

3. On first jump, from a mixin

Sometimes the moment you want to reward lives deep in vanilla code you can't call into directly. There's no "on jump" hook, jumping is a private method on LivingEntity. So we reach in with a mixin and grant on the way through, gated by a boolean so it only fires once:

src/main/java/com/example/example_mod/mixin/LivingEntityJumpMixin.java
package com.example.example_mod.mixin;

import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;

import com.example.example_mod.ExampleAchievements;
import com.periut.retroapi.achievement.RetroAchievements;

import net.minecraft.entity.LivingEntity;
import net.minecraft.entity.player.PlayerEntity;

@Mixin(LivingEntity.class)
public class LivingEntityJumpMixin {

	/** Jumping happens a lot, gate the grant so it only ever runs once per session. */
	private static boolean example_mod$jumpGranted = false;

	@Inject(method = "jump", at = @At("HEAD"))
	private void example_mod$onJump(CallbackInfo ci) {
		if (example_mod$jumpGranted) {
			return;
		}
		LivingEntity self = (LivingEntity) (Object) this;
		if (self instanceof PlayerEntity && !self.world.isRemote) {
			example_mod$jumpGranted = true;
			RetroAchievements.grant(ExampleAchievements.JUMP_FOR_JOY, (PlayerEntity) self);
		}
	}
}

Two details make this mixin correct. First, it targets LivingEntity, the class that declares jump(), not PlayerEntity, which only inherits it; mixins must target the declaring class, so we check instanceof PlayerEntity at runtime instead. Second, the example_mod$jumpGranted boolean gates the whole thing: jumping fires constantly, and without the gate you'd re-grant on every hop. The full mechanics of mixins, why beta modding leans on them, how to cut safely, are the subject of Chapter 13; here, just notice that a grant fits inside one as comfortably as it fits anywhere else.

grant() works identically in singleplayer and multiplayer. Under the hood it's a vanilla stat increment: singleplayer awards it immediately, and a dedicated server sends the vanilla stat packet so the client's toast pops and the icon un-greys. Achievements are per-player, just like vanilla's, one player earning "Jump for Joy" doesn't grant it to anyone else.

That's the whole loop: define an achievement with an icon and a place on the tree, write its two lines of text, decide whether it sits on the vanilla page or a page of your own, and grant it from wherever in your code the moment happens. A toast, an un-greying icon, a small thread of progress, for one method call.

One of our achievements is earned by stepping through a portal into a world that doesn't exist yet. Let's build that world, and the doorway into it.