Dan Matthew
Service

Operator test

System
Nimbus CMS
Skin
Attract mode (arcade)
Entries
on this screen
Menus
8 items
Path
/blog/retroix-arcade-engine-seven-games-one-leaderboard
Credits
Free play
Press ~ to toggle · Esc to close

Writing · 6 Sep 2026

I wrote an arcade engine from scratch, and seven games share one leaderboard

A vanilla-JS game engine, seven games built on it, and one multi-tenant Cloudflare Worker plus D1 holding every high score, keyed by game id.

#javascript #gamedev #cloudflare #showdev

Retroix is a game engine I wrote from scratch in vanilla JavaScript, seven playable arcade games built on top of it, and one shared hall of fame they all report into. No framework, no bundler, and no server for the games themselves. They ship as static files on GitHub Pages. The only backend in the whole thing is a single Cloudflare Worker that holds every high score for every game. The current release is v1.3.0, with all seven games live and verified as of August 2026.

It is the arcade soul of my portfolio, and the reason it is worth writing about is two decisions rather than any one game: building the engine myself instead of importing one, and putting seven games behind one backend instead of seven.

ENGINE LEADERBOARD RUNTIME Engine core vanilla JS, no framework, no build step Game loop fixed-step update + render Input keyboard / touch Render 2D canvas Audio named cues Scenes menu / play / game-over state machine powers Games thin: entities + rules plug into the core ×7 static files on GitHub Pages Game client any of the 7, in the browser static, cookieless runs as POST score top scores Cloudflare Worker one deployment, multi-tenant tenant = gameId writes reads D1 one database, one schema scores.game_id (tenant) Seven games, one Worker, one D1. Migrated from Supabase to Cloudflare in v1.3.0.
⤢ Enlarge

Why from scratch

I could have reached for a game framework. There are good ones. I didn't, for three reasons, and they are the same three I would give today.

Control. When a sprite tears or input feels laggy, I want the bug to be in code I wrote, not three layers down in someone else's scheduler. Owning the loop means owning the fix.

Learning. A fixed-timestep loop, an input layer, a scene stack. You understand these differently once you have built them and watched them misbehave.

Static-site speed. No build pipeline means a game is a handful of files the browser parses on arrival. GitHub Pages serves them, and that is the entire deployment story.

The trade-off is that I own every subsystem. That turned out to be fine. The engine is small enough to read in one sitting, which was the point.

Five subsystems with clear seams

Every game sits on the same core, and the core is five pieces that do not reach into each other.

The game loop runs a fixed-timestep update with a separate render pass. The rules advance in constant ticks regardless of display refresh, so a 144Hz monitor and a throttled phone tab produce the same game state. Getting this right first was the best sequencing decision in the project, because everything else assumes it.

Input normalises keyboard and touch into one action model. A game asks "is left held?" and never finds out whether that was an arrow key or a thumb on the canvas.

Rendering is a thin layer over the 2D canvas API. Games draw with a few primitives and never touch the context directly.

Audio puts short sounds behind a single interface. Games trigger named cues and nothing else.

Scenes are a state machine for menu, play, and game over. Each scene owns its entities; the loop only knows about the current scene.

The payoff is that each game is thin. It brings its entities, its rules, and a couple of scene definitions, and plugs them into the core. Seven games share one loop, one input model, one renderer, one audio layer, one scene stack. When I fixed a touch-input edge case, all seven got the fix in the same commit. That only happens if you keep the games small and let the engine carry the weight.

One leaderboard for seven games

The games run static, but a hall of fame needs somewhere to live. The flow is deliberately boring. A game finishes and the client POSTs a score to the Worker. On the menu it GETs the top scores back and draws them. Analytics are cookieless.

The decision I am happiest with is the shape of that backend. The obvious approach is a backend per game: seven deployments, seven schemas that drift apart the moment I get lazy about one of them. Instead there is one multi-tenant Cloudflare Worker backed by one D1 database, and the game id is the tenant key. Every score row carries a game_id column. Every query is scoped by it. Every route takes it as a parameter. One deployment, one schema, one place to look when something is wrong, and an eighth game means registering a new id rather than provisioning anything.

Multi-tenancy by a single column sounds too simple to be interesting. The simplicity is the feature. The Worker validates the id, the query filters on it, and no game can read or write another game's rows.

The move from Supabase to Cloudflare

The leaderboard did not start on Cloudflare. The first version ran on Supabase, which was quick to stand up and perfectly fine while there was one game. As the arcade grew, two things pushed me to move.

First, the games were already static and edge-served, so a scoreboard hopping off to a Postgres instance felt like the odd piece out. Second, I wanted the "one Worker, one D1, keyed by game id" shape, and that fits Cloudflare's model neatly: a small function with a small SQLite-flavoured database sitting beside it.

The migration is the part I would do again exactly the same way. Write the new Worker with the tenant column from day one. Export the existing scores. Import them with their game id set. Point the clients at the new endpoint. From the games' side the change was one base URL. Because every game already spoke the same score contract, the cutover fit in a single release, v1.3.0, and all seven were verified live afterwards.

What I would tell someone building their own

Build the loop first and get the timestep right, because everything sits on top of it. Keep the games thin so the engine takes the weight, and you get every fix for free across all of them. And when you have n of something that all need the same backend, reach for one deployment with a tenant key before you reach for n deployments. It is far less to remember at two in the morning.

The arcade is live at danmat.github.io/Retroix and the source, engine and all seven games, is at github.com/DanMat/Retroix. Go knock me off the leaderboard, and tell me if you would have kept the per-game backends.