The Screen Is a Stack of Coordinate Systems
A slightly-out-of-order entry in the math sub-series — I’d promised the “3D viewer is mostly arithmetic” checkpoint next, but this week’s work on art, fonts, and resolution kept bumping into one idea, and it wanted writing while it was hot. Same audience as always: high school, early uni, anyone willing to follow an x and a y through a few rooms. The previous posts looked at single curves — a circle, a bent trunk, a curving branch. This one zooms out to the thing all of them eventually have to do: land on a screen.
Here is the unifying claim, and the rest of the post is just five examples of it:
Everything you see on screen has been pushed through a little stack of coordinate systems, and almost every “why is it the wrong size / in the wrong place” bug is one transform applied in the wrong space.
That sounds abstract. It is extremely concrete. Every bug in this week’s flood of posts — the labels that rendered huge, the window that ran off the bottom of the screen, the 3D hotspots that drifted off their branches — was a coordinate-space mistake. Once you can name the spaces, the bugs almost diagnose themselves.
The three spaces
A single frame of this game lives in three coordinate systems, and a point can be expressed in any of them:
- World (room) space. The 960×540 grid the game logic thinks in. The player is at, say, (480, 300). A wall tile is at (160, 96). This is where gameplay happens; it has nothing to do with your monitor.
- GUI space. Also 960×540 — deliberately the same numbers — but a separate layer, used for HUD and menus, pinned to that logical size no matter how big the window gets. A button at GUI (20, 20) is 20 units from the top-left of the canvas, always.
- Window (physical) space. The actual pixels on your display: 1920×1080, or 2560×1440, or whatever fullscreen hands you. This is the only space your eyes ever literally see.
The job of rendering is to get everything from spaces 1 and 2 down into space 3, correctly. A view/camera transform maps world → screen. The GUI is its own fixed layer that the engine scales. And the operating system scales the final result onto the monitor. Three spaces, and the transforms between them.
The bugs happen at the seams — every time a piece of code measures something in one space and uses it in another.
Example 1: why a separate GUI space is worth it
You might ask: if world space and GUI space are both 960×540, why have two? Because they get scaled by different things. World space goes through the camera (which can pan, zoom, and in the 3D viewer, rotate). GUI space is nailed down — it never pans or zooms, so a health bar in the corner stays in the corner.
The payoff shows up in input. A mouse click arrives in window pixels — (1840, 60) on a 1920-wide screen. To know whether the player clicked a button drawn at GUI (20, 20), you have to compare like with like. So you map the click back into GUI space:
device_mouse_x_to_gui(0) // window pixels -> GUI (logical) coordinates
Now the click is in the same 960×540 space the button was laid out in, and “is the cursor inside this rectangle” is an honest comparison. Define the UI once in GUI coordinates, map every click back into GUI coordinates, and the layout works at every window size without a single per-resolution tweak. (This is the decision that made the whole resolution pass nearly free — see the scaling post.)
Example 2: the “contain” fit equation
When the game picks a window size, it wants the largest multiple of 960×540 that still fits on your desktop without hiding behind the taskbar. That’s this:
var _fit = min(1, (display_get_width() * 0.98) / _w,
(display_get_height() * 0.90) / _h);
This is the classic “contain” fit — the same maths that letterboxes a film into a TV. You compute, separately, how much you’d have to scale to fit the width and to fit the height, and you take the smaller of the two (so the bigger dimension is the binding constraint). The min(1, ...) clamps it so you only ever shrink.
The reason it preserves the 16
shape is worth seeing clearly: it’s a single scalar applied to both width and height. If you scaled width and height by different amounts, you’d stretch the picture. One factor, both axes → the ratio is mathematically untouched. Letterboxing (black bars) and “contain” fitting are the same idea seen from two sides: fit the whole thing in, accept empty space (or a smaller window) on the axis that wasn’t the binding constraint.Example 3: projecting 3D onto the screen
The 3D tree viewer has to turn a point in 3D space (where a branch is) into a pixel (where to draw its clickable hotspot). This is the longest transform in the game, and it’s worth walking the whole pipeline because every step is a coordinate change:
var _vp = matrix_multiply(_view, _proj); // combine view + projection
var _cx = _vp[0]*_x + _vp[4]*_y + _vp[8] *_z + _vp[12]; // clip-space x
var _cy = _vp[1]*_x + _vp[5]*_y + _vp[9] *_z + _vp[13]; // clip-space y
var _cz = _vp[2]*_x + _vp[6]*_y + _vp[10]*_z + _vp[14]; // clip-space z
var _cw = _vp[3]*_x + _vp[7]*_y + _vp[11]*_z + _vp[15]; // clip-space w
if (_cw == 0 || _cz < 0) return undefined; // behind the camera? bail
var _ndc_x = _cx / _cw; // the perspective divide
var _ndc_y = _cy / _cw;
return {
x: (_ndc_x * 0.5 + 0.5) * display_get_gui_width(),
y: (1 - (_ndc_y * 0.5 + 0.5)) * display_get_gui_height(),
};
Four steps, four spaces:
- World → clip space. Multiply the 3D point by the combined view-projection matrix. (Those
_vp[0]*_x + ...lines are a 4×4 matrix times a 4-component vector, written out by hand.) This is one transform doing two jobs: the view part moves the world so the camera is at the origin looking down an axis; the projection part sets up the perspective. - The perspective divide. Dividing x and y by
wis what makes far things small.wgrows with distance, so dividing by it shrinks distant points toward the centre — this single division is perspective. It lands you in NDC (normalised device coordinates), a tidy cube where everything visible is between −1 and +1. - NDC → screen.
(_ndc * 0.5 + 0.5)remaps −1…+1 to 0…1, and multiplying by the screen size spreads it across the screen. The1 - (...)on y flips it, because NDC’s y points up and screen-y points down. - …which screen, though? Here’s this week’s bug. That last step multiplies by
display_get_gui_width(), not the window width. The hotspots are drawn in the GUI layer, so they must land in GUI space. When window and GUI were the same size this distinction didn’t matter and the code got away with using window pixels. Add the scaling layer and the coincidence breaks: the hotspots drifted off their branches until the projection was pointed at GUI extents. One transform, wrong space. Exactly the thesis.
And lurking inside _view is the z-up, negated-aspect setup that an earlier post is entirely about — matrix_build_lookat(..., 0, 0, -1) and a negated projection aspect, because this game treats z as “up” (trees grow in z) inside an engine whose screen-y grows downward. That handedness flip deserves its own deeper treatment, and it’s coming.
Example 4: depth is just a coordinate too
In the top-down rooms, who draws in front of whom? When the player walks below a workbench on screen, the player should occlude it; when above, the bench should occlude the player. The entire rule is:
depth = -y;
Lower on the screen (bigger y) → more negative depth → drawn in front. That’s it. “Sorting order” sounds like it needs a sorting algorithm; here it’s a one-line function of a coordinate. The y you already have is the depth, up to a sign.
And there’s a coarser layering knob too: GameMaker’s draw passes. Each frame runs Draw Begin → Draw → Draw End → Draw GUI, in order, and you can put things in different passes to force a stacking that has nothing to do with position:
- The floor is tiled in the main Draw pass at a depth behind everything, so it always sits under the props and player.
- The shed window is drawn in Draw End — after the normal pass — specifically so it paints over the top wall and reads as set into the wall rather than lying on the floor.
- The HUD is in Draw GUI, the last pass, so it floats above the entire world regardless of any depth value.
Passes are layers in time; depth = -y is layering within a pass. Both are just answering “which coordinate decides who’s on top.”
Example 5: a tiny hash that earns its keep
Last one, and my favourite, because it’s the smallest. The floor is three plank variations tiled across the room. Which variation goes on which tile?
var _f = ((_tx div _tw) * 7 + (_ty div _th) * 13) mod _frames;
Take the tile’s grid coordinates (its position divided by tile size), combine them with two multipliers, and take the result mod 3. Two things make this work, and both are the whole point of the post:
It must be a function of position, not of time. The obvious “just pick a random variant per tile” — irandom(2) — runs every frame, so each tile re-rolls 60 times a second and the entire floor shimmers like TV static. A deterministic function of the tile’s coordinates gives every tile a fixed variant that never changes, because the same inputs (the tile’s x and y) always produce the same output. The scatter is frozen into the grid.
The multipliers are coprime-ish so the pattern doesn’t stripe. If you used (tx + ty) mod 3, you’d get diagonal stripes — all the tiles where x + y shares a remainder line up. Multiplying by 7 and 13 (two numbers sharing no small factors with each other or with 3) scrambles the remainders so neighbouring tiles rarely match and no obvious diagonal or grid pattern emerges. It’s a poor man’s hash function: cheap integer arithmetic that turns “where is this tile” into “which of 3 looks does it wear,” with just enough scramble to look unplanned.
A circle is a rotating tangent; a bent trunk is a walk; a floor is a hash of coordinates. Different maths, same job: turn position into appearance.
The lesson, stated once
Every example was the same shape. A point lives in some space. To use it — to click it, to draw it, to sort it, to texture it — you move it into another space, and the move is a transform: a matrix multiply, a divide, a remap, a hash. Get the transform right and it lands where you meant. Get the space wrong — measure in window pixels when you meant GUI, randomise per frame when you meant per tile, add coordinates when you meant to scramble them — and it lands somewhere plausible-but-wrong, which is the hardest kind of wrong to spot.
So when something’s in the wrong place on screen, the first question isn’t “is my maths wrong?” It’s usually “which space is this number actually in, and which space did I assume?” Nine times out of ten, that’s the bug.
Coming next
Back to the originally-scheduled programme. The next math post is the checkpoint I keep promising: the 3D viewer is mostly arithmetic — a walking tour of every kind of maths the viewer leans on (trig, vectors, dot and cross products, projection matrices, parametric curves), one teaser paragraph each, anchored in real screenshots. A map of the country before the next round of deep dives.
After that, where does branch 3 actually start? — the trunk-attachment maths, where a branch’s base is computed in the trunk’s own local frame so it sticks to the right spot on the bark even when the trunk leans. And further out, the deeper cut on z-up-in-a-y-down-world that this post kept gesturing at: the projection sign flips, the lookat conventions, and why the engine’s own 3D tutorials get it subtly wrong for anything past a toy scene.