Home Page Sprite Display, Animation, and Movement.

Breaking Down mechanics.js: A Line-by-Line Walkthrough

This post walks through every line of mechanics.js, a JavaScript module that handles sprite-based hero movement, keyboard input, animation cycling, and boundary collision for a browser game. Each annotated segment connects to the broader system, with numbered footnotes explaining data types, classifications, and roles.


Section 1 — DOM Selection & Position State

const hero = document.querySelector("#hero");  // [¹]
let x = 0;                                     // [²]
let y = 0;                                     // [³]
let step = 2.2;                                // []

hero grabs the DOM element with id #hero — this is the sprite the player controls. Every visual update in the game (position, background-position) writes directly to this element via its .style property.

x and y are the sprite's current coordinates on screen. They start at 0,0 (top-left corner) and are mutated each frame inside gameLoop(). step is how many pixels the sprite moves per frame when a key is held. At 2.2px, movement feels smooth rather than choppy.


Section 2 — Sprite Sheet Configuration

const frameW = 24;      // []
const frameH = 24;      // []
const frameCount = 8;   // []
const rowIndex = 1;     // []
const frameDelay = 2;   // []

These five constants describe the layout of the sprite sheet image being used as the hero's background. The sprite sheet is a grid of frames: each cell is 24×24 pixels. frameCount = 8 means the walk cycle spans 8 columns. rowIndex = 1 selects which row of the sheet to pull from — row 0 might be a different direction or state, row 1 is the active one here.

frameDelay = 2 is a throttle — the animation only advances to the next frame every 2 game loop ticks, preventing the walk cycle from running too fast. All five constants feed directly into the backgroundPosition calculation in gameLoop().


Section 3 — Animation State

let animFrame = 0;            // [¹⁰]
let animTick = 0;             // [¹¹]
let animFrameChanged = true;  // [¹²]

animFrame tracks which column of the sprite sheet is currently visible (0–7). animTick counts raw game loop iterations and resets every time it reaches frameDelay, creating the throttled cadence. animFrameChanged is a dirty flag — it starts true so the first frame renders immediately, and is set true again any time animFrame changes value. The DOM write only happens when this flag is true, avoiding unnecessary style assignments every tick.


Section 4 — Key State Registry

const keys = {};  // [¹³]

An empty object used as a dynamic key map. Each key on the keyboard becomes a property on this object. When a key is pressed the value is true; when released it is false. This pattern allows simultaneous key detection (e.g., W + D for diagonal movement) that switch or if/else chains cannot handle cleanly.


Section 5 — The Animation Function

function heroAnimation(move){          // [¹⁴]
    if (move) {                        // [¹⁵]
        animTick++;                    // [¹⁶]
        if (animTick >= frameDelay){   // [¹⁷]
            animTick = 0;              // [¹⁸]
            let oldFrame = animFrame;  // [¹⁹]
            animFrame = (animFrame + 1) % frameCount;  // [²⁰]
            animFrameChanged = true;   // [²¹]
        }
    } else {
        if (animFrame !== 0){          // [²²]
            animFrame = 0;             // [²³]
            animFrameChanged = true;   // [²⁴]
        }
    };
};

heroAnimation is called once per game loop tick with a boolean move argument. When move is true (a movement key is held), the tick counter increments. Once it hits frameDelay, the counter resets and animFrame advances to the next column in the sprite sheet using modulo arithmetic — so after frame 7 it wraps back to 0. The oldFrame variable is declared but unused; it appears to be a debugging remnant.

When move is false, the function checks if animFrame is already 0. If not, it resets to 0 and sets the dirty flag, returning the sprite to its idle/first frame. If it is already 0, nothing happens — avoiding a redundant DOM write.


Section 6 — Keyboard Event Listeners

document.addEventListener("keydown", (ce) => {   // [²⁵]
    keys[ce.key.toLowerCase()] = true;            // [²⁶]
});

document.addEventListener("keyup", (ce) => {     // [²⁷]
    keys[ce.key.toLowerCase()] = false;           // [²⁸]
});

Two event listeners on the document object catch all keyboard input. On keydown, the key name is lowercased and set to true in the keys map. On keyup, it's set to false. Lowercasing normalizes input so W and w are treated identically. These listeners run independently of the game loop — they just keep the keys object current. The game loop reads from it on every tick.


Section 7 — The Game Loop

function gameLoop(){                                                              // [²⁹]
    let move = keys["w"] || keys["a"] || keys["s"] || keys["d"];                  // [³⁰]

    if (keys["w"]) y -= step;   // [³¹]
    if (keys["a"]) x -= step;   // [³²]
    if (keys["s"]) y += step;   // [³³]
    if (keys["d"]) x += step;   // [³⁴]

    // Boundary checking
    const maxX = window.innerWidth - frameW;    // [³⁵]
    const maxY = window.innerHeight - frameH;   // [³⁶]
    x = Math.max(0, Math.min(x, maxX));         // [³⁷]
    y = Math.max(0, Math.min(y, maxY));         // [³⁸]

    heroAnimation(move);                        // [³⁹]

    if (animFrameChanged){                                                                     // [⁴⁰]
        hero.style.backgroundPosition = `-${animFrame * frameW}px -${rowIndex * frameH}px`;   // [⁴¹]
        animFrameChanged = false;                                                              // [⁴²]
    };

    hero.style.left = x + "px";   // [⁴³]
    hero.style.top  = y + "px";   // [⁴⁴]

    requestAnimationFrame(gameLoop);  // [⁴⁵]
};

gameLoop();  // [⁴⁶]

gameLoop is the engine. Each tick it reads the keys map to compute move, updates x/y based on active keys, clamps both values to screen boundaries, calls heroAnimation, conditionally updates the sprite sheet position, and always writes the new left/top to the DOM. The last line inside the function — requestAnimationFrame(gameLoop) — re-schedules itself, creating an infinite loop that runs in sync with the browser's repaint cycle (~60fps). The gameLoop() call at the bottom bootstraps the whole system.


Section 8 — Commented Debug Code

// const rect = hero.getBoundingClientRect();
// console.log("Visual left edge:",  rect.left);
// console.log("Visual right edge:", rect.right);
// console.log("Visual top edge:",   rect.top);
// console.log("Visual bottom edge:",rect.bottom);
// console.log("Window width:",  window.innerWidth);
// console.log("Window height:", window.innerHeight);

These commented lines are diagnostic tools. getBoundingClientRect() returns the element's actual rendered position and dimensions relative to the viewport — useful for verifying that the CSS left/top values match the visual position and that boundary clamping is working correctly. They are not part of the active system.


Footnotes

[¹]
heroType: Element (HTMLElement) | Classification: DOM reference, const | Role: The single source of truth for the player sprite in the DOM. All visual updates — position and sprite sheet offset — are applied through this reference. Declared with const because the reference itself never changes, only its .style properties do.
[²]
xType: Number (float) | Classification: Mutable state variable, let | Role: Tracks the sprite's horizontal position in pixels from the left edge of the viewport. Modified by keys["a"] (decrement) and keys["d"] (increment) each loop tick. Clamped between 0 and maxX to enforce boundaries. Applied to hero.style.left.
[³]
yType: Number (float) | Classification: Mutable state variable, let | Role: Tracks the sprite's vertical position in pixels from the top of the viewport. Modified by keys["w"] (decrement) and keys["s"] (increment). Clamped between 0 and maxY. Applied to hero.style.top.
[⁴]
stepType: Number (float) | Classification: Configuration constant (declared let, effectively constant) | Role: The number of pixels x or y changes per game loop tick when a movement key is held. At 2.2, movement is sub-pixel smooth without feeling sluggish. Could be changed at runtime to implement speed modifiers.
[⁵]
frameWType: Number (integer) | Classification: Configuration constant, const | Role: The pixel width of a single frame on the sprite sheet. Used in the backgroundPosition calculation (animFrame * frameW) and in boundary clamping (window.innerWidth - frameW).
[⁶]
frameHType: Number (integer) | Classification: Configuration constant, const | Role: The pixel height of a single frame on the sprite sheet. Used in the backgroundPosition Y-axis calculation (rowIndex * frameH) and in vertical boundary clamping.
[⁷]
frameCountType: Number (integer) | Classification: Configuration constant, const | Role: The total number of animation frames in the walk cycle row. Used as the modulo divisor in (animFrame + 1) % frameCount to wrap animFrame back to 0 after the last frame.
[⁸]
rowIndexType: Number (integer) | Classification: Configuration constant, const | Role: Selects which horizontal row of the sprite sheet to read frames from. Row 0 could represent another direction or idle state. rowIndex * frameH computes the Y offset of the backgroundPosition CSS property.
[⁹]
frameDelayType: Number (integer) | Classification: Configuration constant, const | Role: The number of game loop ticks that must pass before animFrame advances. Acts as a frame rate throttle for the animation. A value of 2 means the walk cycle advances at half the loop rate (~30fps animation on a 60fps loop).
[¹⁰]
animFrameType: Number (integer) | Classification: Mutable animation state, let | Role: The current frame index (column) within the active sprite sheet row. Ranges from 0 to frameCount - 1. Drives the X-axis of backgroundPosition. Reset to 0 when the hero stops moving.
[¹¹]
animTickType: Number (integer) | Classification: Mutable counter, let | Role: A sub-counter that increments every game loop tick while move is true. When it reaches frameDelay, it resets to 0 and signals animFrame to advance. Decouples animation speed from loop speed.
[¹²]
animFrameChangedType: Boolean | Classification: Dirty flag, let | Role: Prevents the backgroundPosition style from being rewritten every tick. Set to true only when animFrame actually changes value. The corresponding DOM write in gameLoop fires once per change, then the flag resets to false. Initialized true to force the first frame render.
[¹³]
keysType: Object | Classification: Dynamic state map, const | Role: A plain object used as a hash map of keyboard state. Keys are lowercased key strings (e.g., "w", "a"); values are true (pressed) or false (released). Populated by the keydown/keyup event listeners and read every tick in gameLoop. Supports simultaneous multi-key input.
[¹⁴]
heroAnimation(move)Type: Function | Classification: Named function declaration | Role: Encapsulates all animation state logic. Accepts a single boolean move parameter. Mutates animFrame, animTick, and animFrameChanged. Called once per gameLoop tick after position updates.
[¹⁵]
if (move)Type: Boolean branch | Classification: Control flow | Role: Splits animation behavior into two states: moving (advance the walk cycle) and idle (reset to frame 0). move is a truthy/falsy value derived from the keys map in gameLoop.
[¹⁶]
animTick++Type: Post-increment operator on Number | Classification: State mutation | Role: Increments the tick counter by 1 each time heroAnimation is called with move = true. This is how the delay between frame advances accumulates.
[¹⁷]
if (animTick >= frameDelay)Type: Comparison, Boolean | Classification: Threshold check | Role: Gates the frame advance. Only when animTick has accumulated enough ticks (≥ frameDelay) does the animation move forward. The >= (rather than ===) protects against any scenario where the counter overshoots.
[¹⁸]
animTick = 0Type: Assignment to Number | Classification: Counter reset | Role: Resets the tick counter after the threshold is hit, starting a new delay cycle. Without this, animFrame would advance every tick after the first threshold crossing.
[¹⁹]
let oldFrame = animFrameType: Number (integer) | Classification: Local variable, let | Role: Captures the frame index before it advances. Declared but never used in the current code — appears to be a debugging leftover, possibly intended for change-detection logic that was replaced by the animFrameChanged flag.
[²⁰]
animFrame = (animFrame + 1) % frameCountType: Modulo expression, result Number | Classification: Circular counter | Role: Advances animFrame by 1 and wraps it back to 0 after reaching frameCount - 1. This is the core of the looping walk cycle — without modulo, the index would exceed the sprite sheet bounds.
[²¹]
animFrameChanged = true (inside move block) — Type: Boolean assignment | Classification: Dirty flag set | Role: Signals to gameLoop that a new backgroundPosition value needs to be written to the DOM. Set here immediately after animFrame changes.
[²²]
if (animFrame !== 0)Type: Strict inequality, Boolean | Classification: Guard clause | Role: Prevents redundant work. If the hero is idle and animFrame is already 0, there is nothing to do. This avoids setting animFrameChanged = true (and triggering a DOM write) on every idle tick.
[²³]
animFrame = 0 (idle reset) — Type: Assignment to Number | Classification: State reset | Role: Returns the sprite to its first frame (idle/neutral pose) when movement stops. Combined with the guard on line [²²], this only fires once per transition from moving to idle.
[²⁴]
animFrameChanged = true (inside idle block) — Type: Boolean assignment | Classification: Dirty flag set | Role: Triggers the DOM write in gameLoop to visually snap the sprite back to frame 0 upon stopping.
[²⁵]
document.addEventListener("keydown", ...)Type: EventListener | Classification: Browser event handler | Role: Listens globally for any key press on the page. Fires before a key is released. The callback updates the keys map independently of the game loop, ensuring key state is always current regardless of loop timing.
[²⁶]
keys[ce.key.toLowerCase()] = trueType: Property assignment on Object; ce.key is String | Classification: State write | Role: Registers the pressed key in the keys map. toLowerCase() normalizes case so Shift+W and w are treated the same. ce is the KeyboardEvent object passed to the callback.
[²⁷]
document.addEventListener("keyup", ...)Type: EventListener | Classification: Browser event handler | Role: Mirrors the keydown listener. Fires when a key is released. Without this, keys would remain true in the map indefinitely after being pressed, causing the hero to drift forever.
[²⁸]
keys[ce.key.toLowerCase()] = falseType: Property assignment on Object | Classification: State write | Role: Deactivates the key in the keys map on release. The game loop reads false on the next tick and stops applying movement.
[²⁹]
function gameLoop()Type: Function | Classification: Named function declaration, recursive via requestAnimationFrame | Role: The central update loop of the game. Reads input, updates position, enforces boundaries, drives animation, writes to the DOM, then re-schedules itself. Everything that changes per frame is coordinated here.
[³⁰]
let move = keys["w"] || keys["a"] || keys["s"] || keys["d"]Type: Boolean (logical OR chain) | Classification: Input aggregation | Role: Collapses four key checks into a single boolean. true if any WASD key is currently pressed. Passed to heroAnimation to distinguish moving from idle. Also implicitly used for any future logic that needs to know if the hero is in motion.
[³¹]
if (keys["w"]) y -= stepType: Conditional Number mutation | Classification: Movement input handler | Role: Moves the hero upward by step pixels. In CSS coordinate space, y decreasing moves an element toward the top of the screen.
[³²]
if (keys["a"]) x -= stepType: Conditional Number mutation | Classification: Movement input handler | Role: Moves the hero left by step pixels. Decreasing x moves the element toward the left edge.
[³³]
if (keys["s"]) y += stepType: Conditional Number mutation | Classification: Movement input handler | Role: Moves the hero downward. Increasing y moves toward the bottom of the screen in CSS coordinates.
[³⁴]
if (keys["d"]) x += stepType: Conditional Number mutation | Classification: Movement input handler | Role: Moves the hero right. These four conditionals are not mutually exclusive — pressing W and D simultaneously moves diagonally.
[³⁵]
const maxX = window.innerWidth - frameWType: Number (integer) | Classification: Computed local constant | Role: The maximum valid x value — the rightmost position where the sprite still fits fully within the viewport. Subtracting frameW prevents the sprite from being clipped at the right edge. Recalculated each tick, so it responds to window resizing.
[³⁶]
const maxY = window.innerHeight - frameHType: Number (integer) | Classification: Computed local constant | Role: The maximum valid y value — the lowest position where the sprite still fits within the viewport. Subtracts frameH to prevent bottom-edge clipping.
[³⁷]
x = Math.max(0, Math.min(x, maxX))Type: Number clamp expression | Classification: Boundary enforcement | Role: Constrains x to the range [0, maxX]. Math.min(x, maxX) prevents rightward overflow; Math.max(0, ...) prevents leftward overflow. This is the standard clamp pattern in JavaScript.
[³⁸]
y = Math.max(0, Math.min(y, maxY))Type: Number clamp expression | Classification: Boundary enforcement | Role: Constrains y to the range [0, maxY]. Same clamp pattern as x, applied to the vertical axis.
[³⁹]
heroAnimation(move)Type: Function call | Classification: Animation system invocation | Role: Passes the current movement state to heroAnimation, which mutates animFrame, animTick, and animFrameChanged. Must be called after position updates so move reflects the current tick's input.
[⁴⁰]
if (animFrameChanged)Type: Boolean branch | Classification: Conditional DOM write guard | Role: Prevents writing backgroundPosition to the DOM on every tick. Since DOM style writes can trigger repaints, limiting them to only when the frame actually changes is a performance optimization.
[⁴¹]
hero.style.backgroundPosition = `-${animFrame * frameW}px -${rowIndex * frameH}px`Type: String template literal; CSS value | Classification: DOM style write | Role: Shifts the background image of #hero to reveal the correct sprite frame. The X offset (animFrame * frameW) selects the column; the Y offset (rowIndex * frameH) selects the row. Negative values move the image left/up to bring the target cell into view.
[⁴²]
animFrameChanged = falseType: Boolean assignment | Classification: Dirty flag reset | Role: Clears the flag after the DOM write so backgroundPosition is not rewritten next tick unless heroAnimation sets the flag again.
[⁴³]
hero.style.left = x + "px"Type: String (CSS length value) | Classification: DOM style write | Role: Applies the updated horizontal position to the hero element. Concatenating "px" produces a valid CSS value. Requires #hero to be position: absolute or position: fixed in CSS for left to have effect. Runs every tick regardless of whether x changed.
[⁴⁴]
hero.style.top = y + "px"Type: String (CSS length value) | Classification: DOM style write | Role: Applies the updated vertical position. Same constraints as hero.style.left. Together, these two lines visually move the sprite to its new position each frame.
[⁴⁵]
requestAnimationFrame(gameLoop)Type: Browser API call; accepts Function, returns Number (frame ID) | Classification: Loop continuation | Role: Schedules the next execution of gameLoop to coincide with the browser's next repaint (typically ~16.67ms at 60fps). This is the mechanism that makes gameLoop self-perpetuating. Using requestAnimationFrame rather than setInterval ensures the loop pauses when the tab is backgrounded and stays in sync with the display refresh rate.
[⁴⁶]
gameLoop()Type: Function invocation | Classification: Bootstrap call | Role: Starts the entire game loop for the first time. Without this single call at module load, requestAnimationFrame inside the function would never be reached and nothing would run.