ROSEGOLD
XR
Rosegold XR started as a question about how little a game needs: no framework, no backend, just rules, a renderer and a phone.
It is a vanilla TypeScript app. Vite builds it, Three.js draws it, and Zod checks the data that crosses between modules. Play it on a desktop with the arrow keys, on a phone with swipes, or place the board on a table in AR.
The game is about 2,900 lines in total.
Rules that know
nothing about
drawing.
All of the game rules live in one function that holds the state and hands back a small interface. Nothing outside can change that state, because reading it returns copies.
That split keeps the rules testable, and it means the same game can be shown on a flat screen or on a tabletop without changing a line of logic.
Every tick
does four things.
Small details
that make it
play like Pac-Man.
The player's next turn is remembered: if a turn is blocked, the current direction continues, so an early swipe is not lost. Swipes under 24 pixels are ignored so taps do not steer.
The ghost runs a breadth-first search to the player each tick and takes only the first step of the shortest route.
One interface,
two AR backends.
Where the browser supports WebXR AR, such as Chrome on Android, the game uses it with hit testing and plane detection. On iOS Safari it falls back to Zappar's camera tracking, loaded only when needed.
Both sit behind the same session interface, so the game code asks for a surface and never needs to know which one answered. The board is a single group, so placing it on a table just moves, rotates and scales that group.
Keep the rules small
enough to hold
in your head.
No framework.
No backend.
No excuses.
const step = (): void => {
if (isGameOver) return;
const playerMove = moveCharacter({ grid, position: playerPosition, wantedDirection, currentDirection });
playerPosition = playerMove.position;
currentDirection = playerMove.direction;
score += eatPellet({ grid, position: playerPosition });
ghostPosition = findNextGhostPosition({ grid, ghostPosition, playerPosition });
// ...contact check
};The board always uses one fixed maze, scaled to fit the surface. Generating a maze to fit the room is the obvious next step, and the code for it is written but not yet wired in.