Skip to content

Developer wiki

TRIVAIT edited this page Jul 30, 2026 · 3 revisions

title: MinigamesMod API description: Learn how to create your own minigames using the MinigamesMod API.

MinigamesMod API

Installation

Maven Repository

repositories {
    maven {
        url "https://api.modrinth.com/maven"
    }
}

Dependency

implementation "maven.modrinth:minigamesmod:${project.minigamesmod_version}+mc-${project.minecraft_version}"

fabric.mod.json

{
  "depends": {
    "...": "...",
    "minigamesmod": "*"
  }
}

Creating a Minigame

A minigame usually consists of four classes:

  1. The minigame class
  2. The game screen
  3. A hidden configuration
  4. A visible configuration

The following examples use 2048 as a reference implementation.


1. Minigame Class

Your minigame should extend AbstractMinigame.

Example:

public class Game2048 extends AbstractMinigame {

    public Game2048() {
        super(
            "2048",
            Component.literal("2048"),
            Identifier.fromNamespaceAndPath(
                MinigamesMod.MOD_ID,
                "textures/minigame/2048_icon.png"
            )
        );
    }

    @Override
    public Class<? extends ConfigData> getConfigClass() {
        return Game2048Config.class;
    }

    @Override
    public Class<? extends ConfigData> getVisibleConfigClass() {
        return Game2048VisibleConfig.class;
    }

    @Override
    public @Nullable Leaderboard getLeaderboard() {
        return new Leaderboard(
            "Game2048",
            List.of(Component.translatable("minigame.2048.condition"))
        );
    }

    @Override
    public Screen createScreen(Screen parent) {
        return new Game2048Screen(this, parent);
    }

    @Override
    public void onLose() {
        PlayingSoundManager.playSound(
            SoundEvents.VILLAGER_NO,
            1,
            vol()
        );
    }

    @Override
    public void onWin() {
        PlayingSoundManager.playSound(
            SoundEvents.FIREWORK_ROCKET_BLAST,
            1,
            vol()
        );
    }

    private float vol() {
        return PlayingSoundManager.vol(
            MinigameRegistry.getConfig(TetrisVisibleConfig.class).volume
        );
    }
}

The class extends AbstractMinigame, which already implements most of the required functionality.

The MinigameDefinition interface contains all API methods available for a minigame.

Leaderboards

Addon mods cannot create their own online leaderboards by default.

If you would like leaderboard support for your minigame, please open an issue on GitHub.


2. Game Screen

The game screen contains the actual implementation of your minigame.

This is where rendering, input handling, game logic, animations, and update code should be implemented.

Game2048Screen
public class Game2048Screen extends Screen {
    private final Game2048 minigame;
    private final Screen parent;
    private int size;
    private int[][] grid;
    private int[][] animFromGrid;
    private int[][] animFromColor;
    private long score = 0;
    private boolean gameOver = false;
    private boolean won = false;
    private static final int GAP = 6;
    private static final int BOARD_RADIUS = 6;
    private static final long ANIM_DURATION = 120;
    private static final long COLOR_ANIM_DURATION = 300;
    private final List<TileAnim> animations = new ArrayList<>();
    private long animStartTime = -ANIM_DURATION;
    private long colorAnimStart = 0;
    private boolean colorAnimActive = false;

    private static class TileAnim {
        float fromX, fromY, toX, toY;
        int value;
        boolean merge;
        TileAnim(float fromX, float fromY, float toX, float toY, int value, boolean merge) {
            this.fromX = fromX;
            this.fromY = fromY;
            this.toX = toX;
            this.toY = toY;
            this.value = value;
            this.merge = merge;
        }
    }

    public Game2048Screen(Game2048 minigame, Screen parent) {
        super(Component.translatable("minigame.2048.title"));
        this.minigame = minigame;
        this.parent = parent;
    }

    private static final int PANEL_H = 36;
    private static final int SCORE_H = 44;

    private int cellSize() {
        int availH = this.height - PANEL_H - SCORE_H - 16;
        int maxBoard = Math.min(this.width - 20, availH);
        int cell = (maxBoard - GAP) / size - GAP;
        return Math.min(48, cell);
    }

    private int boardW() {
        return size * (cellSize() + GAP) + GAP;
    }

    private int boardX() {
        return (this.width - boardW()) / 2;
    }

    private int boardY() {
        int usableTop = SCORE_H + 8;
        int usableBot = this.height - PANEL_H;
        return usableTop + (usableBot - usableTop - boardW()) / 2;
    }

    @Override
    protected void init() {
        Game2048VisibleConfig cfg = MinigameRegistry.getConfig(Game2048VisibleConfig.class);
        size = cfg.gridSize;
        grid = new int[size][size];
        animFromGrid = new int[size][size];
        animFromColor = new int[size][size];
        score = 0;
        gameOver = false;
        won = false;
        animations.clear();
        colorAnimActive = false;
        animStartTime = -ANIM_DURATION;
        spawnTile();
        spawnTile();
        rebuildButtons();
    }

    private void rebuildButtons() {
        clearWidgets();
        Button returnButton = SpriteIconButton.builder(Component.empty(), button -> this.onClose(), true)
                .sprite(Identifier.fromNamespaceAndPath(MinigamesMod.MOD_ID, "icon/return"), 15, 15).build();
        returnButton.setTooltip(Tooltip.create(Component.translatable("minigame.2048.undo")));
        returnButton.setRectangle(20, 20, 10, 10);

        Button restartButton = SpriteIconButton.builder(Component.empty(), button -> game2048Init(), true)
                .sprite(Identifier.fromNamespaceAndPath(MinigamesMod.MOD_ID, "icon/restart"), 15, 15).build();
        restartButton.setTooltip(Tooltip.create(Component.translatable("minigame.restart")));
        restartButton.setRectangle(20, 20, 35, 10);

        this.addRenderableWidget(restartButton);
        this.addRenderableWidget(returnButton);
        this.addRenderableWidget(new ConfigButton(60, 10, minigame));
    }

    private void game2048Init() {
        Game2048VisibleConfig cfg = MinigameRegistry.getConfig(Game2048VisibleConfig.class);
        size = cfg.gridSize;
        grid = new int[size][size];
        animFromGrid = new int[size][size];
        animFromColor = new int[size][size];
        score = 0;
        gameOver = false;
        won = false;
        animations.clear();
        colorAnimActive = false;
        animStartTime = -ANIM_DURATION;
        spawnTile();
        spawnTile();
        rebuildButtons();
    }

    private void spawnTile() {
        List<int[]> empty = new ArrayList<>();
        for (int r = 0; r < size; r++)
            for (int c = 0; c < size; c++)
                if (grid[r][c] == 0) empty.add(new int[]{r, c});
        if (empty.isEmpty()) return;
        int[] pos = empty.get((int)(Math.random() * empty.size()));
        grid[pos[0]][pos[1]] = Math.random() < 0.9 ? 2 : 4;
    }

    @Override
    public boolean keyPressed(KeyEvent input) {
        if (gameOver) return super.keyPressed(input);
        int dr = 0, dc = 0;
        Random random = new Random();
        switch (input.key()) {
            case GLFW.GLFW_KEY_UP, GLFW.GLFW_KEY_W -> {
                dr = -1;
                PlayingSoundManager.playSound(SoundEvents.ITEM_FRAME_ROTATE_ITEM, random.nextFloat(0.9f, 1.2f), vol());
            }
            case GLFW.GLFW_KEY_DOWN, GLFW.GLFW_KEY_S -> {
                dr = 1;
                PlayingSoundManager.playSound(SoundEvents.ITEM_FRAME_ROTATE_ITEM, random.nextFloat(0.9f, 1.2f), vol());
            }
            case GLFW.GLFW_KEY_LEFT, GLFW.GLFW_KEY_A -> {
                dc = -1;
                PlayingSoundManager.playSound(SoundEvents.ITEM_FRAME_ROTATE_ITEM, random.nextFloat(0.9f, 1.2f), vol());
            }
            case GLFW.GLFW_KEY_RIGHT, GLFW.GLFW_KEY_D -> {
                dc = 1;
                PlayingSoundManager.playSound(SoundEvents.ITEM_FRAME_ROTATE_ITEM, random.nextFloat(0.9f, 1.2f), vol());
            }
            default -> {
                return super.keyPressed(input);
            }
        }
        if (slide(dr, dc)) {
            spawnTile();
            if (!canMove()) {
                gameOver = true;
                saveBestScore();
                minigame.onLose();
                Game2048VisibleConfig cfg = MinigameRegistry.getConfig(Game2048VisibleConfig.class);
                if (cfg.gridSize == 4) {
                    minigame.getLeaderboard().doPost(Minecraft.getInstance().getUser().getName(), (int) score, true);
                }
            }
        }
        return true;
    }

    private boolean slide(int dr, int dc) {
        boolean moved = false;
        boolean[][] merged = new boolean[size][size];
        int cell = cellSize();
        int bx = boardX(), by = boardY();
        int[][] snapshot = new int[size][size];
        int[][] snapshotColor = new int[size][size];

        for (int r = 0; r < size; r++) {
            snapshot[r] = Arrays.copyOf(grid[r], size);
            for (int c = 0; c < size; c++) {
                snapshotColor[r][c] = grid[r][c] == 0 ? 0xFFCDC1B4 : tileColor(grid[r][c]);
            }
        }

        int rowStart = dr > 0 ? size - 1 : 0, rowEnd = dr > 0 ? -1 : size, rowStep = dr > 0 ? -1 : 1;
        int colStart = dc > 0 ? size - 1 : 0, colEnd = dc > 0 ? -1 : size, colStep = dc > 0 ? -1 : 1;

        animations.clear();

        for (int r = rowStart; r != rowEnd; r += rowStep) {
            for (int c = colStart; c != colEnd; c += colStep) {
                if (grid[r][c] == 0) continue;
                int tr = r, tc = c;
                while (true) {
                    int nr = tr + dr, nc = tc + dc;
                    if (nr < 0 || nr >= size || nc < 0 || nc >= size) break;
                    if (grid[nr][nc] == 0) {
                        tr = nr;
                        tc = nc;
                    } else if (grid[nr][nc] == grid[r][c] && !merged[nr][nc]) {
                        tr = nr;
                        tc = nc;
                        break;
                    } else break;
                }
                if (tr == r && tc == c) continue;

                boolean isMerge = grid[tr][tc] == grid[r][c] && !merged[tr][tc];
                float fx = bx + GAP + c * (cell + GAP) + cell / 2f;
                float fy = by + GAP + r * (cell + GAP) + cell / 2f;
                float tx = bx + GAP + tc * (cell + GAP) + cell / 2f;
                float ty = by + GAP + tr * (cell + GAP) + cell / 2f;

                animations.add(new TileAnim(fx, fy, tx, ty, grid[r][c], isMerge));

                if (isMerge) {
                    grid[tr][tc] *= 2;
                    score += grid[tr][tc];
                    merged[tr][tc] = true;
                    if (grid[tr][tc] == 2048 && !won) {
                        won = true;
                        saveBestScore();
                        minigame.onWin();
                        minigame.getLeaderboard().doPost(Minecraft.getInstance().getUser().getName(), (int) score, true);
                    }
                } else {
                    grid[tr][tc] = grid[r][c];
                }
                grid[r][c] = 0;
                moved = true;
            }
        }

        if (moved) {
            animFromGrid = snapshot;
            animFromColor = snapshotColor;
            animStartTime = System.currentTimeMillis();
            colorAnimStart = System.currentTimeMillis();
            colorAnimActive = true;
        }

        return moved;
    }

    private void saveBestScore() {
        Game2048Config cfg = MinigameRegistry.getConfig(Game2048Config.class);
        if (score > cfg.bestScore) {
            cfg.bestScore = score;
            AutoConfig.getConfigHolder(Game2048Config.class).save();
        }
    }

    private boolean canMove() {
        for (int r = 0; r < size; r++)
            for (int c = 0; c < size; c++) {
                if (grid[r][c] == 0) return true;
                if (r + 1 < size && grid[r][c] == grid[r + 1][c]) return true;
                if (c + 1 < size && grid[r][c] == grid[r][c + 1]) return true;
            }
        return false;
    }

    private int tileColor(int val) {
        return switch (val) {
            case 2 -> 0xFFEEE4DA;
            case 4 -> 0xFFEDE0C8;
            case 8 -> 0xFFF2B179;
            case 16 -> 0xFFF59563;
            case 32 -> 0xFFF67C5F;
            case 64 -> 0xFFF65E3B;
            case 128 -> 0xFFEDCF72;
            case 256 -> 0xFFEDCC61;
            case 512 -> 0xFFEDC850;
            case 1024 -> 0xFFEDC53F;
            case 2048 -> 0xFFEDC22E;
            default -> 0xFF3C3A32;
        };
    }

    private int lerpColor(int from, int to, float t) {
        int ar = (from >> 16) & 0xFF, ag = (from >> 8) & 0xFF, ab = from & 0xFF;
        int br = (to >> 16) & 0xFF, bg = (to >> 8) & 0xFF, bb = to & 0xFF;
        return 0xFF000000 |
                ((int)(ar + (br - ar) * t) << 16) |
                ((int)(ag + (bg - ag) * t) << 8) |
                (int)(ab + (bb - ab) * t);
    }

    private void fillRoundedRect(GuiGraphicsExtractor context, int x, int y, int w, int h, int r, int color) {
        context.fill(x + r, y, x + w - r, y + h, color);
        context.fill(x, y + r, x + r, y + h - r, color);
        context.fill(x + w - r, y + r, x + w, y + h - r, color);
        for (int i = 0; i < r; i++) {
            for (int j = 0; j < r; j++) {
                if (Math.sqrt((double)(r - 1 - i) * (r - 1 - i) + (double)(r - 1 - j) * (r - 1 - j)) < r) {
                    context.fill(x + i, y + j, x + i + 1, y + j + 1, color);
                    context.fill(x + w - 1 - i, y + j, x + w - i, y + j + 1, color);
                    context.fill(x + i, y + h - 1 - j, x + i + 1, y + h - j, color);
                    context.fill(x + w - 1 - i, y + h - 1 - j, x + w - i, y + h - j, color);
                }
            }
        }
    }

    private void drawTile(GuiGraphicsExtractor context, int val, int x, int y, int cell, int color) {
        fillRoundedRect(context, x, y, cell, cell, 4, color);
        String label = String.valueOf(val);
        float targetH = cell * 0.42f;
        float scale = targetH / 9f;
        int textW = font.width(label);
        float scaleW = (float)(cell - 8) / (textW * scale > 0 ? textW : 1);
        if (scaleW < 1f) scale *= scaleW;
        var matrices = context.pose();
        matrices.pushMatrix();
        matrices.translate(x + cell / 2f, y + cell / 2f - (9f * scale) / 2f);
        matrices.scale(scale, scale);
        context.centeredText(font, label, 0, 0, 0xFFFFFFFF);
        matrices.popMatrix();
    }

    @Override
    public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
        super.extractRenderState(context, mouseX, mouseY, delta);
        int cell = cellSize();
        int bw = boardW();
        int bx = boardX(), by = boardY();
        long now = System.currentTimeMillis();
        float moveP = Math.min(1f, (float)(now - animStartTime) / ANIM_DURATION);
        float eased = 1f - (1f - moveP) * (1f - moveP);
        boolean animating = moveP < 1f && !animations.isEmpty();
        float colorT = colorAnimActive ? Math.min(1f, (float)(now - colorAnimStart) / COLOR_ANIM_DURATION) : 1f;
        if (colorAnimActive && colorT >= 1f) colorAnimActive = false;

        fillRoundedRect(context, bx, by, bw, bw, BOARD_RADIUS, 0xFFBBADA0);

        for (int r = 0; r < size; r++)
            for (int c = 0; c < size; c++)
                fillRoundedRect(context, bx + GAP + c * (cell + GAP), by + GAP + r * (cell + GAP), cell, cell, 4, 0xFFCDC1B4);

        if (animating) {
            for (int r = 0; r < size; r++) {
                for (int c = 0; c < size; c++) {
                    int val = animFromGrid[r][c];
                    if (val == 0) continue;
                    boolean movedAway = false;
                    for (TileAnim a : animations) {
                        int sr = Math.round((a.fromY - by - GAP) / (cell + GAP));
                        int sc = Math.round((a.fromX - bx - GAP) / (cell + GAP));
                        if (sr == r && sc == c) {
                            movedAway = true;
                            break;
                        }
                    }
                    if (!movedAway) {
                        int x = bx + GAP + c * (cell + GAP);
                        int y = by + GAP + r * (cell + GAP);
                        int col = animFromColor[r][c];
                        drawTile(context, val, x, y, cell, col);
                    }
                }
            }

            for (TileAnim anim : animations) {
                float ax = anim.fromX + (anim.toX - anim.fromX) * eased;
                float ay = anim.fromY + (anim.toY - anim.fromY) * eased;
                float sf = anim.merge ? (1f + 0.15f * (float)Math.sin(moveP * Math.PI)) : 1f;
                int ts = (int)(cell * sf);
                int col = tileColor(anim.value);
                drawTile(context, anim.value, (int)(ax - ts / 2f), (int)(ay - ts / 2f), ts, col);
            }

        } else {
            animations.clear();
            for (int r = 0; r < size; r++) {
                for (int c = 0; c < size; c++) {
                    int val = grid[r][c];
                    if (val == 0) continue;
                    int x = bx + GAP + c * (cell + GAP);
                    int y = by + GAP + r * (cell + GAP);
                    int col = tileColor(val);
                    if (colorAnimActive) {
                        int fromCol = animFromColor[r][c];
                        col = lerpColor(fromCol, col, colorT);
                    }
                    drawTile(context, val, x, y, cell, col);
                }
            }
        }

        long best = MinigameRegistry.getConfig(Game2048Config.class).bestScore;
        int sbW = bw/2-4, sbH = 32;
        int sx = bx + sbW+8, sy = by - sbH - 6;

        fillRoundedRect(context, sx, sy, sbW, sbH, 4, 0xFFBBADA0);
        context.centeredText(font, Component.translatable("minigame.2048.score"), sx + sbW / 2, sy + 4, 0xFFEEE4DA);
        context.centeredText(font, Component.literal(String.valueOf(score)), sx + sbW / 2, sy + 16, 0xFFFFFFFF);

        int bsX = bx;
        fillRoundedRect(context, bsX, sy, sbW, sbH, 4, 0xFFBBADA0);
        context.centeredText(font, Component.translatable("minigame.2048.best"), bsX + sbW / 2, sy + 4, 0xFFEEE4DA);
        context.centeredText(font, Component.literal(String.valueOf(best)), bsX + sbW / 2, sy + 16, 0xFFFFFFFF);

        if (gameOver || won) {
            context.fill(bx, by, bx + bw, by + bw, 0xAA776E65);
            Component msg = gameOver ? Component.translatable("minigame.2048.game_over") : Component.translatable("minigame.2048.win");
            int msgColor = gameOver ? 0xFFFF4444 : 0xFF44FF88;
            context.centeredText(font, msg, this.width / 2, by + bw / 2 - 10, msgColor);
            context.centeredText(font, Component.literal(String.valueOf(score)), this.width / 2, by + bw / 2 + 4, 0xFFFFFFFF);
        }
    }

    private float vol() {
        return PlayingSoundManager.vol(MinigameRegistry.getConfig(Game2048VisibleConfig.class).volume);
    }

    @Override
    public void onClose() {
        minigame.onStop();
        this.minecraft.gui.setScreen(parent);
    }

    @Override
    public boolean isPauseScreen() {
        return false;
    }
}

3. Hidden and Visible Configurations

Both configuration classes use Cloth Config API together with Auto Config.

The hidden configuration is typically used to store internal data such as the player's best score.

@Config(name = "minigames/2048")
public class Game2048Config implements ConfigData {

    public long bestScore = 0;

}

The visible configuration contains settings that players can change from the configuration screen.

@Config(name = "minigames/2048_ui")
public class Game2048VisibleConfig implements ConfigData {

    @ConfigEntry.BoundedDiscrete(min = 3, max = 8)
    public int gridSize = 4;

    @ConfigEntry.BoundedDiscrete(min = 0, max = 100)
    public int volume = 100;

}

4. API Functions

Getting a configuration

Use MinigameRegistry.getConfig() to retrieve a configuration instance.

Game2048Config config = MinigameRegistry.getConfig(Game2048Config.class);

Playing sounds

To simplify sound playback, use PlayingSoundManager.

PlayingSoundManager.playSound(soundEvent);
PlayingSoundManager.playSound(soundEvent, pitch, volume);

Utility class:

public class PlayingSoundManager {

    public static void playSound(SoundEvent soundEvent, float pitch, float volume) {
        Minecraft.getInstance().getSoundManager().play(
            SimpleSoundInstance.forUI(soundEvent, pitch, volume)
        );
    }

    public static void playSound(SoundEvent soundEvent) {
        Minecraft.getInstance().getSoundManager().play(
            SimpleSoundInstance.forUI(soundEvent, 1, 1)
        );
    }

    public static float vol(int volumeConfig) {
        return 5 * ((float) volumeConfig / 100);
    }
}

Opening the visible configuration

To open the visible configuration screen (for example from a button), call:

MinigameRegistry.openVisibleConfig(minigame, parentScreen);

Where:

  • minigame is your MinigameDefinition
  • parentScreen is the screen that should be returned to after closing the configuration

5. Registering Your Minigame

Register your minigame during client initialization.

For example, inside onInitializeClient():

MinigameRegistry.register(new ExampleMinigame());

After registration, your minigame will automatically appear in the MinigamesMod menu.