Introducción al Desarrollo de Videojuegos con GameMaker · Module 8: HUDs, Interfaces, Menus, and Pause Systems
HUD Design (Score, Lives, and Collectibles)
Lesson 37 of 60 · 60 min
Also available in: ES
Introduction to HUD Design in 2D Games
The Heads-Up Display (HUD) serves as the primary communication bridge between the game's internal state and the player. Through this visual interface, players instantly receive vital information regarding their survival and overall progress: remaining health points, current lives, collected coins or gems, and accumulated score. In the previous lesson, we explored the purpose of the Draw GUI event and how it fundamentally differs from the standard Draw event by operating within a static coordinate space independent of camera movement and room coordinates. In this lesson, we will focus on the complete programmatic and visual implementation of the elements that form a clean, readable, and cohesive HUD for a 2D platformer.
Designing a HUD is more than placing text strings and colored boxes onto the screen; it requires understanding how GameMaker processes rendering queues, how to manage global draw states without disrupting other visual assets, and how to present crucial data without cluttering the core gameplay area. Step by step, we will build a complete interface system featuring a layered energy bar with dynamic color shifts, an aligned numeric score counter, an icon-backed coin counter, and a modular row of hearts representing the player's available lives.
💡 Remember that all functions prefixed with draw_ must be called inside events within the Draw pipeline. For the HUD, always use the Draw GUI event exclusively. Placing these rendering calls inside the Step event will either throw an error or fail to display anything, as GameMaker restricts graphic pipeline operations to designated draw cycles.
System Architecture: The UI Controller Object
A common mistake when starting game development is placing interface rendering code directly inside the playable character object (such as obj_player). While this may feel convenient because the player instance holds variables like health and lives, it violates the Single Responsibility Principle and creates brittle architecture. If the player dies and the instance is removed via instance_destroy(), the HUD disappears instantly from the screen, causing cascade runtime crashes when other systems query interface data or leaving the screen blank during respawn transitions.
The industry standard pattern delegates interface rendering to a dedicated controller object, commonly named obj_hud, obj_gui, or integrated within a global obj_game_manager. This manager object should be persistent and require no physical representation in the game world (no collision mask and no visible sprite assigned to its body). Its sole role is reading values from global variables or character instances and translating them onto the screen using geometric primitives, custom typography, and interface sprites.
Typography Setup: Creating and Configuring Fonts
By default, calling draw_text() without specifying an assigned font forces GameMaker to fall back on an unstyled system font lacking consistent scaling or retro aesthetics. To achieve a polished presentation, we create dedicated font resources directly in the Asset Browser. Right-click on the Fonts folder, select Create -> Font, and name the asset fnt_hud.
Within the Font Editor, you can select any typeface installed on your operating system. For pixel-art titles, monospaced or dedicated bitmap fonts designed for low-resolution grids (such as Consolas, Courier New, or open-source pixel fonts like Kenney Pixel) yield the cleanest results. Configure an appropriate point size (such as 16 or 20 pt) and toggle the Anti-aliasing option according to your art direction: disabling anti-aliasing ensures sharp, crisp pixel edges reminiscent of classic 8-bit and 16-bit consoles.

Text Rendering: Score and Collectible Counters
Text rendering in GameMaker operates through an immediate-mode draw state machine. This means that whenever you adjust the active color using draw_set_color() or modify alignment with draw_set_halign(), those adjustments persist across every subsequent draw call in the entire project until explicitly reset. Grasping this behavior is crucial to prevent unexpected bugs, such as world sprites accidentally tinting red or unrelated dialog boxes inheriting wrong alignments.
To render score and collected coins, we rely on draw_set_font(), draw_set_color(), draw_set_halign(), draw_set_valign(), and draw_text(). The draw_text(x, y, string) function accepts the screen coordinates where text will anchor alongside the target string. Because values like global.puntos and global.monedas are stored as numeric Real types, convert them to text using string() or standard string concatenation with the addition operator (+).
// Draw GUI Event in obj_hud
// 1. Configure draw states
draw_set_font(fnt_hud);
draw_set_color(c_white);
draw_set_halign(fa_left);
draw_set_valign(fa_top);
// 2. Base coordinates for top-left anchor
var _base_x = 32;
var _base_y = 64;
// 3. Render text with drop shadow to ensure legibility
var _texto_puntos = "SCORE: " + string(global.puntos);
// Black shadow offset 2 pixels down and right
draw_set_color(c_black);
draw_text(_base_x + 2, _base_y + 2, _texto_puntos);
// Primary white text
draw_set_color(c_white);
draw_text(_base_x, _base_y, _texto_puntos);
// 4. Proactive reset of global draw states
draw_set_color(c_white);
draw_set_halign(fa_left);
draw_set_valign(fa_top);Notice the drop-shadow rendering pattern used above: we draw the identical text string first in c_black at a slight offset (such as x + 2, y + 2) and immediately follow it with the foreground color at the actual anchor. This drop-shadow technique is vital across dynamic video game levels where backgrounds shift between bright skies and dark caves, ensuring characters and numbers never lose contrast or readability against moving scene geometry.
Dynamic Indicator Bars: Health and Energy
Visualizing player vitality can be approached in two ways: utilizing GameMaker's built-in draw_healthbar() function, or constructing custom segmented meters using primitive rectangles and sliced sprites. We will first examine the built-in option for rapid prototyping and then implement a custom modular bar that provides higher visual fidelity.
Using the draw_healthbar() Function
The draw_healthbar() function calculates and renders a dynamic bar that empties based on an input value. Its signature takes eleven arguments governing bounding coordinates, fill percentage, background colors, gradient thresholds, depletion direction, and decorative borders:
draw_healthbar(x1, y1, x2, y2, amount, backcol, mincol, maxcol, direction, showback, showborder);A frequent point of confusion is the 'amount' parameter. This argument does not expect raw health values (such as 3 out of 5 hit points), but rather a normalized percentage strictly scaled between 0 and 100. Supplying an unnormalized value directly causes the bar to render incorrect ratios. Always normalize the value using the ratio formula: (current_hp / max_hp) * 100.
// Draw GUI Event in obj_hud
// Verify that player instance exists to prevent read crashes
if (instance_exists(obj_player)) {
var _salud_actual = obj_player.hp;
var _salud_maxima = obj_player.hp_max;
// Calculate normalized percentage between 0 and 100
var _porcentaje = (_salud_actual / _salud_maxima) * 100;
// Bar bounding box coordinates
var _bx1 = 32;
var _by1 = 24;
var _bx2 = 232;
var _by2 = 44;
// Render health bar with dynamic gradient from red (min) to lime (max)
draw_healthbar(
_bx1, _by1, _bx2, _by2,
_porcentaje,
c_dkgray, // Background color when bar depletes
c_red, // Color when health reaches critical level (near 0%)
c_lime, // Color when health is full (near 100%)
0, // Direction: 0 = depletes from right to left
true, // Draw background backing
true // Draw outer 1px black outline
);
}Building Custom Health Bars with Primitives
While draw_healthbar() excels for quick tests, its visual options are limited: outlines remain a hardcoded 1-pixel line and it cannot accommodate bevels, glows, or inner padding. For a commercial look, construct a layered bar using draw_rectangle(). The structure relies on three distinct layers rendered back-to-front:
| Layer | Technical Role | Suggested Color |
|---|---|---|
| 1. Outer Frame (Border) | Defines total container bounds and pops against level backgrounds. | Solid black or deep charcoal (c_black / make_color_rgb(20,20,20)) |
| 2. Inner Backing (Trough) | Represents lost health capacity and empty meter space. | Dark gray or muted maroon (make_color_rgb(60,20,20)) |
| 3. Dynamic Fill (Health) | Variable-width rectangle scaled to the active health percentage. | Emerald green, vivid cyan, or dynamic alert red |
// Draw GUI Event in obj_hud - Custom Layered Health Bar
if (instance_exists(obj_player)) {
var _hp = obj_player.hp;
var _hp_max = obj_player.hp_max;
var _fraccion = clamp(_hp / _hp_max, 0, 1);
var _x = 32;
var _y = 20;
var _ancho_total = 200;
var _alto_total = 18;
var _grosor_borde = 2;
// Layer 1: Dark outer border
draw_set_color(c_black);
draw_rectangle(_x - _grosor_borde, _y - _grosor_borde, _x + _ancho_total + _grosor_borde, _y + _alto_total + _grosor_borde, false);
// Layer 2: Empty bar background trough
draw_set_color(make_color_rgb(50, 50, 50));
draw_rectangle(_x, _y, _x + _ancho_total, _y + _alto_total, false);
// Layer 3: Dynamic health fill with status-driven color shifts
var _color_salud = c_lime;
if (_fraccion < 0.25) {
_color_salud = c_red;
} else if (_fraccion < 0.5) {
_color_salud = c_yellow;
}
draw_set_color(_color_salud);
var _ancho_actual = _ancho_total * _fraccion;
if (_ancho_actual > 0) {
draw_rectangle(_x, _y, _x + _ancho_actual, _y + _alto_total, false);
}
// Reset default draw color
draw_set_color(c_white);
}In the code above, clamp() mathematically constrains the health fraction between 0 and 1, guarding the render pipeline against negative values or visual glitches during overhealing events. Furthermore, evaluating thresholds with if/else if logic alerts players by shifting the bar color from lime to yellow and red as their vitality enters critical territory.
Iconic Indicators: Lives and Collectibles with Sprites
Not every interface element belongs in a plain text block or numeric meter. Discrete resources such as extra lives or special artifacts are best communicated through repeated graphic icons. For instance, rather than displaying 'LIVES: 3', drawing three heart sprites horizontally in a clean row provides immediate, universally understood feedback.
To achieve this pattern, we pair standard for loops with draw_sprite() or draw_sprite_ext(). The draw_sprite_ext() variation is especially versatile, allowing you to define horizontal and vertical scaling, rotation angle, color blend, and alpha transparency per icon on the fly.
// Draw GUI Event in obj_hud - Rendering Life and Coin Icons
// ---------------------------------------------------------
// SECTION 1: DRAW LIVES (ROW OF HEARTS)
// ---------------------------------------------------------
var _vidas_totales = global.vidas;
var _corazon_x_inicio = 32;
var _corazon_y = 52;
var _espaciado = 28; // Pixel distance between each consecutive icon
for (var i = 0; i < _vidas_totales; i++) {
// Calculate horizontal offset for each successive heart
var _pos_x = _corazon_x_inicio + (i * _espaciado);
// Render sprite spr_hud_heart (subimage 0, x, y)
draw_sprite(spr_hud_heart, 0, _pos_x, _corazon_y);
}
// ---------------------------------------------------------
// SECTION 2: DRAW COIN COUNTER
// ---------------------------------------------------------
var _moneda_x = 32;
var _moneda_y = 88;
// Draw collectible coin icon
draw_sprite(spr_coin_ui, 0, _moneda_x, _moneda_y);
// Configure typography and vertical alignment for adjacent number
draw_set_font(fnt_hud);
draw_set_halign(fa_left);
draw_set_valign(fa_middle);
// Coin quantity string prefixed with 'x'
var _texto_monedas = "x " + string(global.monedas);
// Shadow
draw_set_color(c_black);
draw_text(_moneda_x + 30 + 1, _moneda_y + 12 + 1, _texto_monedas);
// Foreground text in bright yellow
draw_set_color(c_yellow);
draw_text(_moneda_x + 30, _moneda_y + 12, _texto_monedas);
// Final state cleanup
draw_set_color(c_white);
draw_set_valign(fa_top);Consider the step-by-step math within the heart loop: on the first iteration (i = 0), the horizontal position is 32 + (0 * 28) = 32. On the second iteration (i = 1), the position becomes 32 + (1 * 28) = 60. On the third iteration (i = 2), the position is 32 + 56 = 88. This dynamic spacing guarantees a consistent, scalable layout: if the player obtains an extra life container increasing global.vidas to 4, the loop renders the additional icon automatically without requiring code modifications.
Consolidated Integration: The obj_hud Object
To maintain clean structure throughout your platformer project, assemble all HUD elements into a unified controller object called obj_hud. Below is the complete architecture, featuring variable initialization in the Create event followed by coordinated rendering inside the Draw GUI event.
// =========================================================
// OBJECT: obj_hud
// EVENT: Create
// =========================================================
// Set explicit canvas size for the GUI layer (native UI resolution)
display_set_gui_size(960, 540);
// Visual smoothing variables for animated transitions
barra_hp_visual = 100;
// Ensure global variables exist in case obj_hud initializes first
if (!variable_global_exists("puntos")) {
global.puntos = 0;
}
if (!variable_global_exists("monedas")) {
global.monedas = 0;
}
if (!variable_global_exists("vidas")) {
global.vidas = 3;
}Calling display_set_gui_size(960, 540) in the Create event is a core best practice in GameMaker. By default, the GUI surface stretches to match whatever window or display resolution the hardware runs at. Establishing a fixed virtual resolution for the GUI layer ensures your pixel offsets (like x = 32 or y = 20) preserve identical visual proportions regardless of whether the game runs at 720p, 1080p, or 4K.
// =========================================================
// OBJECT: obj_hud
// EVENT: Draw GUI
// =========================================================
// Check if player exists to extract vitality stats
var _hp_actual = 0;
var _hp_maxima = 100;
if (instance_exists(obj_player)) {
_hp_actual = obj_player.hp;
_hp_maxima = obj_player.hp_max;
}
// 1. DRAW HEALTH BAR (Top Left)
var _ancho_barra = 180;
var _alto_barra = 16;
var _pos_x = 24;
var _pos_y = 20;
var _porcentaje_vida = clamp(_hp_actual / _hp_maxima, 0, 1);
// Outer border
draw_set_color(c_black);
draw_rectangle(_pos_x - 2, _pos_y - 2, _pos_x + _ancho_barra + 2, _pos_y + _alto_barra + 2, false);
// Empty background trough
draw_set_color(make_color_rgb(40, 40, 40));
draw_rectangle(_pos_x, _pos_y, _pos_x + _ancho_barra, _pos_y + _alto_barra, false);
// Active health fill
if (_porcentaje_vida > 0) {
var _color_barra = (_porcentaje_vida > 0.3) ? make_color_rgb(70, 210, 70) : make_color_rgb(220, 50, 50);
draw_set_color(_color_barra);
draw_rectangle(_pos_x, _pos_y, _pos_x + (_ancho_barra * _porcentaje_vida), _pos_y + _alto_barra, false);
}
// 2. DRAW LIVES (Heart icons below health bar)
var _vidas_espacio = 26;
for (var i = 0; i < global.vidas; i++) {
draw_sprite(spr_hud_heart, 0, _pos_x + (i * _vidas_espacio), _pos_y + 24);
}
// 3. DRAW COINS (Below hearts)
draw_sprite(spr_coin_ui, 0, _pos_x, _pos_y + 56);
draw_set_font(fnt_hud);
draw_set_halign(fa_left);
draw_set_valign(fa_middle);
var _str_monedas = "x " + string(global.monedas);
draw_set_color(c_black);
draw_text(_pos_x + 25, _pos_y + 67, _str_monedas);
draw_set_color(make_color_rgb(255, 215, 0)); // Gold tint
draw_text(_pos_x + 24, _pos_y + 66, _str_monedas);
// 4. DRAW SCORE (Top Right Corner)
var _gui_ancho = display_get_gui_width();
draw_set_halign(fa_right);
draw_set_valign(fa_top);
var _str_score = "SCORE: " + string(global.puntos);
// Score drop shadow
draw_set_color(c_black);
draw_text(_gui_ancho - 22, 22, _str_score);
// Score foreground
draw_set_color(c_white);
draw_text(_gui_ancho - 24, 20, _str_score);
// 5. RESTORE DEFAULT DRAW STATES
draw_set_color(c_white);
draw_set_halign(fa_left);
draw_set_valign(fa_top);| GML Function | Primary Purpose | Usage Recommendation |
|---|---|---|
| draw_text(x, y, string) | Renders typographic string data onto the active surface. | Pair consistently with draw_set_font() to maintain visual styles. |
| draw_set_halign(halign) | Sets horizontal text alignment (fa_left, fa_center, fa_right). | Use fa_right when pinning counters to the right margin. |
| draw_set_valign(valign) | Sets vertical text alignment (fa_top, fa_middle, fa_bottom). | Use fa_middle to center text vertically against interface sprites. |
| draw_healthbar(...) | Renders standard percentage meters with built-in color gradients. | Requires the amount argument to be normalized strictly between 0 and 100. |
| draw_rectangle(x1, y1, x2, y2, outline) | Draws filled or outlined geometric quadrilaterals. | Essential for custom status bars, frames, and background backings. |
| display_set_gui_size(w, h) | Sets a fixed virtual resolution for the Draw GUI layer. | Call once inside the Create event of the HUD controller. |
| display_get_gui_width() | Returns the active pixel width of the Draw GUI surface. | Crucial for calculating dynamic right-hand margins. |
Advanced Techniques: Number Formatting and Visual Smoothing
In classic arcade titles, high scores are rarely displayed as a raw '45'; instead, they are zero-padded to a fixed character width (such as '000045'). This convention preserves UI visual stability, preventing the text container from jittering or expanding each time the score rolls into tens, hundreds, or thousands.
GameMaker provides the built-in function string_format(val, total, dec), where 'total' indicates the minimum total columns and 'dec' the decimal precision. However, string_format pads empty slots using spaces rather than zeros. To achieve zero-padded scores, combine it with string_replace_all():
// Helper logic to format numbers with leading zeros
// Converts an integer into a fixed-length zero-padded string
var _puntos_crudos = global.puntos;
var _puntos_formateados = string_replace_all(string_format(_puntos_crudos, 6, 0), " ", "0");
// If global.puntos is 350, _puntos_formateados produces "000350"
draw_text(_gui_ancho - 24, 20, "SCORE: " + _puntos_formateados);Another high-impact enhancement is visual health bar smoothing (lerp animation). When the player takes heavy damage, rather than letting the meter snap down instantly in a single frame, store an animated visual variable that smoothly chases the real health stat using linear interpolation (lerp). Inside the Step event of obj_hud, compute: barra_hp_visual = lerp(barra_hp_visual, obj_player.hp, 0.1); and use barra_hp_visual to determine the fill width inside the Draw GUI event. This adds a layer of kinetic polish ('game juice') to your combat feedback.
Best Practices and Troubleshooting Common Mistakes
When developing UI systems in GameMaker, beginners often run into a few recurring stumbling blocks. The first is 'draw state leakage': modifying draw_set_color(c_red) to style an alert label without restoring it back to c_white causes every subsequent object in the render pipeline without explicit tinting to inherit that red tint. As a golden rule, every Draw GUI event should conclude by resetting color to c_white and alignments to fa_left and fa_top.
A second common issue occurs when pinning elements to the right or bottom edges using hardcoded pixel positions like x = 900. If the player resizes the window or plays on an ultrawide monitor, the counter will either drift out of bounds or float awkwardly in the center. Always calculate responsive margins using display_get_gui_width() and display_get_gui_height(). A coordinate like display_get_gui_width() - 32 will consistently sit exactly 32 pixels from the right margin regardless of the display resolution.
Practical Exercises
Complete the following structured exercises to solidify the interface architecture covered in this lesson:
1. Zero-Padded Coin Counter: Modify the coin display inside the Draw GUI event of obj_hud so that the quantity always renders with exactly three digits (e.g., 'x 005', 'x 023', 'x 100'). Verification criteria: Collect coins during play and confirm that the leading zeros decrement proportionally without misaligning the text string relative to the icon.
2. Smooth Health Depletion (Lerp): Declare a visual variable hp_visual in the Create event of obj_hud. In the Step event, interpolate hp_visual toward the player's true hp using lerp(hp_visual, obj_player.hp, 0.15). Render the custom health bar using this smoothed value in the Draw GUI event. Verification criteria: Have an enemy deal damage to the player and observe the health bar drain in a smooth fluid animation rather than jumping instantly.
3. Empty Heart Containers: Modify the heart loop so that it always draws a fixed total of 3 heart slots (representing maximum life capacity). Use subimage 0 (filled red heart) when the loop index is less than global.vidas, and subimage 1 (an empty or grayed-out heart silhouette) when the index is greater than or equal to global.vidas. Verification criteria: Reduce global.vidas to 1 during gameplay and confirm the screen displays 1 filled heart and 2 empty containers in the row.
Summary
- The HUD should be managed by an independent controller object (obj_hud) and drawn exclusively within the Draw GUI event.
- The GUI layer operates on screen coordinates completely decoupled from camera viewports. Calling display_set_gui_size() establishes a consistent virtual resolution across all displays.
- GameMaker handles text rendering via a persistent state machine: always reset draw_set_font, draw_set_color, and alignment functions back to defaults after drawing.
- Drop shadows drawn with a small 1-2 pixel offset in black ensure UI text remains legible against high-contrast backgrounds.
- Health meters can be prototyped using draw_healthbar() (with values normalized from 0 to 100) or styled with multi-layered primitive rectangles for custom visual depth.
- For loops make it easy to draw modular rows of repeated icon sprites (hearts, keys, or ammo) by calculating incremental horizontal offsets per iteration.
Practise what you learned
Exercise 1 · 1 point
Why is it recommended to create a dedicated controller object (such as obj_hud) to manage the user interface instead of placing the drawing code inside obj_player?
Exercise 2 · 1 point
Arrange the steps required to implement a drop shadow effect when rendering score text in the Draw GUI event, following the pattern taught in the lesson:
- 1Set the draw color to the foreground tone (c_white) and draw the text at the original base coordinates.
- 2Configure initial draw states (font, color, and horizontal/vertical alignments).
- 3Set the draw color to black (c_black) and draw the text slightly offset from the base coordinates.
- 4Define base coordinates and store the text converted with the string() function.
- 5Reset draw states to their default values as a defensive programming practice.
Exercise 3 · 1 point
The 'amount' parameter in draw_healthbar() requires a normalized percentage between 0 and 100 calculated using the formula: (current_hp / max_hp) * 100. If the character currently has 15 health points out of a maximum of 60, what numeric value should this parameter receive? (Enter only the number).
Sign in to save your progress — Sign in