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
- [¹]
-
hero— Type: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 withconstbecause the reference itself never changes, only its.styleproperties do. ↩ - [²]
-
x— Type:Number(float) | Classification: Mutable state variable,let| Role: Tracks the sprite's horizontal position in pixels from the left edge of the viewport. Modified bykeys["a"](decrement) andkeys["d"](increment) each loop tick. Clamped between0andmaxXto enforce boundaries. Applied tohero.style.left. ↩ - [³]
-
y— Type:Number(float) | Classification: Mutable state variable,let| Role: Tracks the sprite's vertical position in pixels from the top of the viewport. Modified bykeys["w"](decrement) andkeys["s"](increment). Clamped between0andmaxY. Applied tohero.style.top. ↩ - [⁴]
-
step— Type:Number(float) | Classification: Configuration constant (declaredlet, effectively constant) | Role: The number of pixelsxorychanges per game loop tick when a movement key is held. At2.2, movement is sub-pixel smooth without feeling sluggish. Could be changed at runtime to implement speed modifiers. ↩ - [⁵]
-
frameW— Type:Number(integer) | Classification: Configuration constant,const| Role: The pixel width of a single frame on the sprite sheet. Used in thebackgroundPositioncalculation (animFrame * frameW) and in boundary clamping (window.innerWidth - frameW). ↩ - [⁶]
-
frameH— Type:Number(integer) | Classification: Configuration constant,const| Role: The pixel height of a single frame on the sprite sheet. Used in thebackgroundPositionY-axis calculation (rowIndex * frameH) and in vertical boundary clamping. ↩ - [⁷]
-
frameCount— Type: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) % frameCountto wrapanimFrameback to0after the last frame. ↩ - [⁸]
-
rowIndex— Type:Number(integer) | Classification: Configuration constant,const| Role: Selects which horizontal row of the sprite sheet to read frames from. Row0could represent another direction or idle state.rowIndex * frameHcomputes the Y offset of thebackgroundPositionCSS property. ↩ - [⁹]
-
frameDelay— Type:Number(integer) | Classification: Configuration constant,const| Role: The number of game loop ticks that must pass beforeanimFrameadvances. Acts as a frame rate throttle for the animation. A value of2means the walk cycle advances at half the loop rate (~30fps animation on a 60fps loop). ↩ - [¹⁰]
-
animFrame— Type:Number(integer) | Classification: Mutable animation state,let| Role: The current frame index (column) within the active sprite sheet row. Ranges from0toframeCount - 1. Drives the X-axis ofbackgroundPosition. Reset to0when the hero stops moving. ↩ - [¹¹]
-
animTick— Type:Number(integer) | Classification: Mutable counter,let| Role: A sub-counter that increments every game loop tick whilemoveistrue. When it reachesframeDelay, it resets to0and signalsanimFrameto advance. Decouples animation speed from loop speed. ↩ - [¹²]
-
animFrameChanged— Type:Boolean| Classification: Dirty flag,let| Role: Prevents thebackgroundPositionstyle from being rewritten every tick. Set totrueonly whenanimFrameactually changes value. The corresponding DOM write ingameLoopfires once per change, then the flag resets tofalse. Initializedtrueto force the first frame render. ↩ - [¹³]
-
keys— Type: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 aretrue(pressed) orfalse(released). Populated by thekeydown/keyupevent listeners and read every tick ingameLoop. Supports simultaneous multi-key input. ↩ - [¹⁴]
-
heroAnimation(move)— Type:Function| Classification: Named function declaration | Role: Encapsulates all animation state logic. Accepts a single booleanmoveparameter. MutatesanimFrame,animTick, andanimFrameChanged. Called once pergameLooptick after position updates. ↩ - [¹⁵]
-
if (move)— Type:Booleanbranch | Classification: Control flow | Role: Splits animation behavior into two states: moving (advance the walk cycle) and idle (reset to frame 0).moveis a truthy/falsy value derived from thekeysmap ingameLoop. ↩ - [¹⁶]
-
animTick++— Type: Post-increment operator onNumber| Classification: State mutation | Role: Increments the tick counter by 1 each timeheroAnimationis called withmove = 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 whenanimTickhas accumulated enough ticks (≥frameDelay) does the animation move forward. The>=(rather than===) protects against any scenario where the counter overshoots. ↩ - [¹⁸]
-
animTick = 0— Type: Assignment toNumber| Classification: Counter reset | Role: Resets the tick counter after the threshold is hit, starting a new delay cycle. Without this,animFramewould advance every tick after the first threshold crossing. ↩ - [¹⁹]
-
let oldFrame = animFrame— Type: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 theanimFrameChangedflag. ↩ - [²⁰]
-
animFrame = (animFrame + 1) % frameCount— Type: Modulo expression, resultNumber| Classification: Circular counter | Role: AdvancesanimFrameby 1 and wraps it back to0after reachingframeCount - 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:Booleanassignment | Classification: Dirty flag set | Role: Signals togameLoopthat a newbackgroundPositionvalue needs to be written to the DOM. Set here immediately afteranimFramechanges. ↩ - [²²]
-
if (animFrame !== 0)— Type: Strict inequality,Boolean| Classification: Guard clause | Role: Prevents redundant work. If the hero is idle andanimFrameis already0, there is nothing to do. This avoids settinganimFrameChanged = true(and triggering a DOM write) on every idle tick. ↩ - [²³]
-
animFrame = 0(idle reset) — Type: Assignment toNumber| 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:Booleanassignment | Classification: Dirty flag set | Role: Triggers the DOM write ingameLoopto visually snap the sprite back to frame0upon 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 thekeysmap independently of the game loop, ensuring key state is always current regardless of loop timing. ↩ - [²⁶]
-
keys[ce.key.toLowerCase()] = true— Type: Property assignment onObject;ce.keyisString| Classification: State write | Role: Registers the pressed key in thekeysmap.toLowerCase()normalizes case soShift+Wandware treated the same.ceis theKeyboardEventobject passed to the callback. ↩ - [²⁷]
-
document.addEventListener("keyup", ...)— Type:EventListener| Classification: Browser event handler | Role: Mirrors thekeydownlistener. Fires when a key is released. Without this, keys would remaintruein the map indefinitely after being pressed, causing the hero to drift forever. ↩ - [²⁸]
-
keys[ce.key.toLowerCase()] = false— Type: Property assignment onObject| Classification: State write | Role: Deactivates the key in thekeysmap on release. The game loop readsfalseon the next tick and stops applying movement. ↩ - [²⁹]
-
function gameLoop()— Type:Function| Classification: Named function declaration, recursive viarequestAnimationFrame| 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.trueif any WASD key is currently pressed. Passed toheroAnimationto 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 -= step— Type: ConditionalNumbermutation | Classification: Movement input handler | Role: Moves the hero upward bysteppixels. In CSS coordinate space,ydecreasing moves an element toward the top of the screen. ↩ - [³²]
-
if (keys["a"]) x -= step— Type: ConditionalNumbermutation | Classification: Movement input handler | Role: Moves the hero left bysteppixels. Decreasingxmoves the element toward the left edge. ↩ - [³³]
-
if (keys["s"]) y += step— Type: ConditionalNumbermutation | Classification: Movement input handler | Role: Moves the hero downward. Increasingymoves toward the bottom of the screen in CSS coordinates. ↩ - [³⁴]
-
if (keys["d"]) x += step— Type: ConditionalNumbermutation | Classification: Movement input handler | Role: Moves the hero right. These four conditionals are not mutually exclusive — pressingWandDsimultaneously moves diagonally. ↩ - [³⁵]
-
const maxX = window.innerWidth - frameW— Type:Number(integer) | Classification: Computed local constant | Role: The maximum validxvalue — the rightmost position where the sprite still fits fully within the viewport. SubtractingframeWprevents the sprite from being clipped at the right edge. Recalculated each tick, so it responds to window resizing. ↩ - [³⁶]
-
const maxY = window.innerHeight - frameH— Type:Number(integer) | Classification: Computed local constant | Role: The maximum validyvalue — the lowest position where the sprite still fits within the viewport. SubtractsframeHto prevent bottom-edge clipping. ↩ - [³⁷]
-
x = Math.max(0, Math.min(x, maxX))— Type:Numberclamp expression | Classification: Boundary enforcement | Role: Constrainsxto 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:Numberclamp expression | Classification: Boundary enforcement | Role: Constrainsyto the range[0, maxY]. Same clamp pattern asx, applied to the vertical axis. ↩ - [³⁹]
-
heroAnimation(move)— Type: Function call | Classification: Animation system invocation | Role: Passes the current movement state toheroAnimation, which mutatesanimFrame,animTick, andanimFrameChanged. Must be called after position updates somovereflects the current tick's input. ↩ - [⁴⁰]
-
if (animFrameChanged)— Type:Booleanbranch | Classification: Conditional DOM write guard | Role: Prevents writingbackgroundPositionto 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:Stringtemplate literal; CSS value | Classification: DOM style write | Role: Shifts the background image of#heroto 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 = false— Type:Booleanassignment | Classification: Dirty flag reset | Role: Clears the flag after the DOM write sobackgroundPositionis not rewritten next tick unlessheroAnimationsets 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#heroto beposition: absoluteorposition: fixedin CSS forleftto have effect. Runs every tick regardless of whetherxchanged. ↩ - [⁴⁴]
-
hero.style.top = y + "px"— Type:String(CSS length value) | Classification: DOM style write | Role: Applies the updated vertical position. Same constraints ashero.style.left. Together, these two lines visually move the sprite to its new position each frame. ↩ - [⁴⁵]
-
requestAnimationFrame(gameLoop)— Type: Browser API call; acceptsFunction, returnsNumber(frame ID) | Classification: Loop continuation | Role: Schedules the next execution ofgameLoopto coincide with the browser's next repaint (typically ~16.67ms at 60fps). This is the mechanism that makesgameLoopself-perpetuating. UsingrequestAnimationFramerather thansetIntervalensures 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,requestAnimationFrameinside the function would never be reached and nothing would run. ↩