As part of my site redesign, I wanted my site to feel unique and represent me. I've been part of many software interviews and have seen some personal websites that have wow'd me and others that made me wonder why they even bothered. I decided I wanted to create a video game feel for my site. I wanted it to be fun, but not overly distracting, immersive but still performant. So I built a lightweight tile-based procedural map generator and layered an animated character on top. This post is a peek at the approach I used and a few tiny code snippets to show my thought process.
The rendering strategy
Initially, I just wanted to have a background that looked like a video game. I used css to create a grid of tiles and then used a noise function to create a random pattern of tiles. That was fun, but I wanted to take it a step further. I wanted to have a character that would move around the screen and interact with the tiles. And the tiles should be more interesting than just a random pattern of colored squares. So I found some free pixel art tiles and a character sprite sheet and had some fun.
I split the background and the character into two separate canvases. This keeps things clean: one canvas handles the tile map and batching, and the other just focuses on drawing a single animated frame for the character. Both canvases are scaled to keep the pixel art crisp, and the main UI sits on top of everything else.
Core idea
- One canvas renders a procedurally generated tile map
- Another canvas renders a single character with an animation player
- The UI is just normal HTML, with the canvases fixed underneath.
Procedural tiles in a nutshell
Under the hood, the map starts as a small array of numbers. I seed a quick, deterministic hash to get a nice sprinkle of values across the grid, then bucket those values into rough terrain: floor, rock, water. That gives me a semantic map that's easy to reason about. Only after that do I translate the semantics into actual sprite names from a manifest. It's a tiny layer that makes swapping art (or adding a new tileset) basically free.
A minimal version of the tile render loop looks like this:
// inside a requestAnimationFrame callback
ctx.fillStyle = '#0a0f1f'
ctx.fillRect(0, 0, width, height)
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const idx = tiles[y * cols + x] ?? 0
ctx.fillStyle = palette[idx]
ctx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE)
}
}Then, when I switch from colored rects to real tiles, I just draw regions from a sprite sheet using a tiny helper that understands the grid.
// draw a grid-aligned tile by index
function drawTileToGrid(ctx, sheet, index, gridX, gridY, scale = 1) {
const dx = Math.floor(gridX * sheet.tileWidth * scale)
const dy = Math.floor(gridY * sheet.tileHeight * scale)
sheet.draw(ctx, index, dx, dy, scale)
}Sprite sheets and animation clips
Each character action (Idle, Walk, Run, Attack, etc.) lives in its own horizontal strip. I infer tile size from the image height, then compute the frame count as width / height. From that I build anAnimationClip with frames [0..n-1] and a default fps. This discovery step keeps the pipeline hands-off: drop in a new strip and it just works, no manual manifests to babysit.



The tiny animation player is basically an accumulator and a frame pointer:
class AnimationPlayer {
constructor(sheet, clip) {
this.sheet = sheet
this.clip = {...clip}
this.accumulator = 0
this.frameIndex = 0
this.direction = 1
}
update(deltaMs) {
const frameDuration = 1000 / (this.clip.fps ?? 8)
this.accumulator += deltaMs
while (this.accumulator >= frameDuration) {
this.accumulator -= frameDuration
this.frameIndex = (this.frameIndex + 1) % this.clip.frames.length
}
}
draw(ctx, dx, dy, scale = 1) {
const tileIndex = this.clip.frames[this.frameIndex] ?? 0
this.sheet.draw(ctx, tileIndex, dx, dy, scale)
}
}Input and one-shot actions
Movement is mapped to arrow keys and WASD. Holding Shift doubles the speed (runs). I also support one-shot actions (Q/E for attacks) using an internal lock that plays an animation once and ignores movement until it completes, then returns to idle.
const keys = getKeyState()
let speed = baseSpeed
if (keys.shift) speed *= 2
if (!animator.isLocked(now)) {
if (keys.q) animator.playOnce('Attack1')
else if (keys.e) animator.playOnce('Attack2')
else animator.setAction(isMoving ? 'Walk' : 'Idle')
}
animator.update(dtMs)
animator.draw(ctx, x, y, scale)Attacks are “one-shot” animations. When you press Q or E, I swap the clip and set a short internal lock until the animation finishes. During that window, movement input is ignored so the attack can play cleanly, then it snaps back to Idle. It’s simple state, but it makes the controls feel intentional.
Layering in the app shell
I mount both canvases once at the root route (this website uses TanStack Router) so they persist across page navigations. That small trick gives the site a continuous-world vibe without re-rendering the background on every route. The content lives at a higherz-index, and I tone the background down a bit with opacity/brightness so text stays crisp.
Final thoughts
I ran into an easy-to-miss production bug when I deployed to Vercel: several sprite assets had spaces and + in filenames. Locally, the browser found them, but the CDN served 404s until I encoded each path segment. The fix was to build public URLs as /segment1/segment2/file with encodeURIComponent on each segment.
function buildUrl(dir, file) {
const segs = [...dir.split('/').filter(Boolean), file]
return '/' + segs.map(encodeURIComponent).join('/')
}Overall, it was a fun project that helped me learn a lot about canvas rendering and animation. I'm happy with the result, but I'm probably going to change it up a bit in the future.
This was my first blog post about learning out loud. Thanks for reading!
PS: If you spot the character chilling behind the UI while you scroll, Yah, that's intentional. Say hi with the arrow keys.
