🎯 My First GameMaker Game · Episode 5 of 5 · See All Episodes
🗡️ Episode 5 · Series Finale · Top-Down RPG

Quest On!
GML RPG Basics

The GameMaker finale: a top-down RPG with 8-direction movement, an NPC with a dialogue box, state-based enemies, hearts-and-hits combat, and a saved game, the Undertale-shaped skill set.

👶 Ages 10+ ⏱️ ~2 Hours 🎯 GameMaker ✓ Free
🎮 8-way movement 💬 Dialogue box 🧠 Enemy states ⚔️ Combat & hearts 🚪 Room warps 💾 Save & load
0 XP
Level 1
🔥0
Your Progress 0 / 6 steps
1
🎮
8-Way Movement & Depth
Normalised diagonals and y-sorting
Active
🎯
Goal for this step

Build top-down movement that is fair on diagonals and sorts by depth.

  • 1New project: obj_player (32×32), obj_wall, a walled test room.
  • 2Read both axes, then NORMALISE diagonals, otherwise diagonal walking is 41% faster (the classic top-down bug!).
  • 3Reuse your Episode 2 place_meeting blocks for both axes (that code follows you everywhere).
  • 4Depth illusion: depth = -y; in Step (or y-sorted layers), lower on screen draws in front. Instant fake 3D.
obj_player · Step
var dx = keyboard_check(vk_right) - keyboard_check(vk_left);
var dy = keyboard_check(vk_down)  - keyboard_check(vk_up);

// normalise the diagonal
if (dx != 0 && dy != 0) {
    dx *= 0.7071;
    dy *= 0.7071;
}
hsp = dx * 3;
vsp = dy * 3;
// ... place_meeting blocks from Episode 2 ...

depth = -y;    // lower on screen = in front
⌨️
Code Challenge
+20 XP
Fix the diagonal speed bug:
obj_player · Step
if (dx != 0 && dy != 0) {
    dx *= ;
    dy *= 0.7071;
}
depth = ;
💡 Hint: 1/√2 ≈ 0.7071 shortens the diagonal to unit length; depth sorting uses the negative of the vertical position.
🧠
Knowledge Check
+15 XP
Without normalising, moving diagonally is faster because…
AKeys stack bonuses
BYou add full speed on BOTH axes, the diagonal of a 1×1 square is 1.41, not 1
CGameMaker boosts diagonals
2
💬
The Dialogue Box
Talk to an NPC with a typewriter effect
Locked
🎯
Goal for this step

Build an interaction system with a classic RPG text box.

  • 1obj_npc: stands still; when the player is near (distance_to_object < 40) show a floating "Z" prompt (Draw event).
  • 2Pressing Z near them: create obj_dialogue, passing an array of lines.
  • 3obj_dialogue freezes the player (a global.locked flag the player’s Step respects), draws a box + text along the bottom, advances lines on Z, destroys itself after the last.
  • 4The typewriter effect: draw string_copy(text, 1, chars) and grow chars each Step, 2 characters per frame feels right.
obj_dialogue · Draw GUI
draw_set_alpha(0.85);
draw_rectangle_color(40, 380, 760, 500,
    c_black, c_black, c_black, c_black, false);
draw_set_alpha(1);

chars = min(chars + 2, string_length(lines[page]));
draw_text_ext(60, 400,
    string_copy(lines[page], 1, chars), 24, 640);
✏️
Fill in the Blanks
+15 XP
Proximity is measured with . The typewriter reveal draws a growing of the line, while a global flag freezes the player.
🧠
Knowledge Check
+15 XP
Why freeze player movement during dialogue?
ATo save CPU
BFocus and state discipline: talking IS a state, mixed states (walking away mid-line) create bugs and confusion
CNPCs demand respect
3
🧠
Enemy States
IDLE / CHASE / ATTACK with a switch statement
Locked
🎯
Goal for this step

Build a slime with a proper state machine brain.

  • 1obj_slime Create: state = "idle"; plus speed/aggro values.
  • 2Step is ONE switch: idle → wander randomly, spot player within 120px → chase; chase → move toward player (point_direction + lengthdir!), lose them past 200px → idle; within 24px → attack.
  • 3lengthdir_x/y(spd, dir) converts an angle into movement, GameMaker’s gift to top-down games.
  • 4Give the slime image_blend tints per state (green/yellow/red) to SEE the brain working.
obj_slime · Step
switch (state) {
    case "idle":
        if (distance_to_object(obj_player) < 120) state = "chase";
        break;

    case "chase":
        var dir = point_direction(x, y, obj_player.x, obj_player.y);
        x += lengthdir_x(1.5, dir);
        y += lengthdir_y(1.5, dir);
        if (distance_to_object(obj_player) > 200) state = "idle";
        if (distance_to_object(obj_player) < 24)  state = "attack";
        break;

    case "attack":
        // damage happens in step 4
        state = "chase";
        break;
}
⌨️
Code Challenge
+20 XP
Chase the player using angle maths:
obj_slime · Step
var dir = (x, y, obj_player.x, obj_player.y);
x += (1.5, dir);
y += lengthdir_y(1.5, dir);
💡 Hint: One function returns the angle toward a point; the lengthdir pair converts angle + speed into x and y movement.
🧠
Knowledge Check
+15 XP
The state machine (idle/chase/attack) beats one big pile of ifs because…
ASwitch is faster
BEach behaviour lives in its own case with explicit transitions, readable, debuggable, extendable
CGML requires switch
4
⚔️
Combat: Hearts & Hits
Player HP, sword swings and invincibility frames
Locked
🎯
Goal for this step

Implement two-way combat with i-frames.

  • 1Player: global.hp = 6 (half-hearts!), drawn as hearts in Draw GUI.
  • 2Slime attack: on contact, hp -= 1, knock the player back (lengthdir away from the slime), and grant invincibility frames, a 45-frame timer during which contact does nothing and the player flashes (visible = !visible every 4 frames).
  • 3Player attack: X spawns obj_swing (a short-lived hitbox in front, alarm-destroyed after 10 frames); its collision with obj_slime does damage + knockback.
  • 4Without i-frames, touching an enemy drains all hearts in half a second, THE most common beginner action-game bug.
obj_player (combat bits)
/// hit by slime:
if (iframes <= 0) {
    global.hp -= 1;
    iframes = 45;
    var dir = point_direction(other.x, other.y, x, y);
    x += lengthdir_x(12, dir);   // knockback
    y += lengthdir_y(12, dir);
}

/// Step:
if (iframes > 0) {
    iframes--;
    visible = (iframes div 4) % 2 == 0;  // flash
} else visible = true;
✏️
Fill in the Blanks
+15 XP
The post-hit immunity window is called invincibility (i-frames), here lasting frames, during which the sprite by toggling visible.
🧠
Knowledge Check
+15 XP
i-frames exist because without them…
AEnemies get stuck
BContact damage applies EVERY frame, one touch drains full health in under a second
CKnockback fails
5
🚪
World & Warps
Multiple rooms stitched into a world
Locked
🎯
Goal for this step

Connect rooms with door objects that preserve player position.

  • 1Build 2-3 rooms: village (NPC, no enemies), forest (slimes), cave (many slimes + treasure).
  • 2obj_warp: invisible rectangle with variables target_room, target_x, target_y (set per-instance in the room editor’s Variables panel!).
  • 3Collision with player: store target coords in globals, room_goto(target_room); a persistent obj_game repositions the player on Room Start.
  • 4Make obj_player persistent so HP and state survive room changes, one checkbox.
  • 5Suddenly it is a WORLD, not a screen.
obj_warp · Collision with obj_player
global.warp_x = target_x;
global.warp_y = target_y;
room_goto(target_room);

/// obj_game · Room Start:
if (instance_exists(obj_player)) {
    obj_player.x = global.warp_x;
    obj_player.y = global.warp_y;
}
✏️
Fill in the Blanks
+15 XP
Per-instance values like target_room are set in the room editor’s panel. The player survives room changes by being marked .
🧠
Knowledge Check
+15 XP
Instance variables editable per-placement (this door → cave, that door → village) give you…
ASlower rooms
BOne reusable warp object configured per door, data-driven doors, no per-door objects
CAutomatic maps
6
💾
Saving & The Finale
Write a real save file, then wrap the series
Locked
🎯
Goal for this step

Save and load game state to disk, and close out five episodes.

  • 1Saving in GML: build a struct of what matters, json_stringify it, write with the file functions.
  • 2Save on pressing F5 at the village elder (diegetic save points!); load with F9 or a title-screen Continue.
  • 3Loading: read the file, json_parse, apply hp/room/position.
  • 4Series complete! You wielded: events, instances, place_meeting, alarms, particles, cameras, states, lengthdir, persistence and save files, the full GameMaker toolkit. Undertale started exactly here. Your move. 🎯
obj_game (save/load)
/// save:
var data = {
    hp:   global.hp,
    rm:   room,
    px:   obj_player.x,
    py:   obj_player.y,
    coins: global.coins
};
var f = file_text_open_write("save.json");
file_text_write_string(f, json_stringify(data));
file_text_close(f);

/// load:
if (file_exists("save.json")) {
    var f = file_text_open_read("save.json");
    var data = json_parse(file_text_read_string(f));
    file_text_close(f);
    global.hp = data.hp;
    room_goto(data.rm);
}
⌨️
Code Challenge
+20 XP
Serialise the save data:
obj_game
file_text_write_string(f, (data));
// and on load:
var data = (file_text_read_string(f));
💡 Hint: One function turns a struct into JSON text; its mirror turns JSON text back into a struct.
🧠
Knowledge Check
+15 XP
JSON as a save format is smart because…
AIt is encrypted
BIt is human-readable and structured, you can open the file, read it, and debug saves by eye
CIt only works in GML
🎉🏆🎮✨🎉
Workshop Complete!
Dialogue, enemy AI states, combat and real save files, five episodes from Pong to an RPG. GameMaker is officially your engine. 🎯🏆
0
Total XP
1
Level
0
Best Streak
0%
Accuracy
⭐ View My Progress & Certificates

This workshop was free and took many hours to build. If it helped you learn something new, consider supporting the project.

☕ Support on Ko-fi