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.
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.
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.
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. 🎯🏆