Dungeotto is a vibe-coded puzzle game about a chubby Egyptian sarcophagus escaping from a sinking temple.
After playing a lot of Rush Hour puzzles (on Michael Fogleman's website), I wanted to make a variant. Each chamber is still a sliding-block puzzle : drag the stones along their axis, free the sarcophagus through the north gate, walk the corridor into the next room. Lava rises in real time for the whole run. If it touches the sarcophagus, the descent ends. Crowding of blocks repeats every 24 chambers, with a vase shop between cycles; the lava does not reset.
The tech stack
Since I program all day, I wanted to write as little code as possible : I vibe-coded Dungeotto in three weekends (a total of around 15h) and spent my time choosing the stack, defining the constraints, and reviewing what came back.
.gif)
- TypeScript + Vite + PixiJS 8 for the game. I chose PixiJS because this game did not need a physics engine. A rendering library was enough for the job. Agents are also very good at JavaScript and web frameworks (iterative by nature). I also appreciated this PixiJS skill.
- Vitest as the contract with hundreds of cases.
- Tauri 2 for the Windows
.exe. I then packaged the game as a Windows build with Tauri. - Trystero for peer-to-peer split-screen. WebRTC when it can, TURN when it must, Nostr as the signaling, so a desktop build can talk to a phone.
- In-game editors so taste stays mine.
/?editis the tile picker./?edit=audiois the sound workbench. Picks write toshippedSkin.jsonandshippedAudio.json. - An offline synthetic lab that plays solution-informed traces against real oracle, economy, and lava rules. It tests internal coherence, not whether a human is having fun.
Bitboards
Michael Fogleman enumerated millions of interesting boards and published the results. His article also introduced me to bitboards. A 6×6 board has 36 cells, so occupancy fits in a 64-bit integer: one bit per cell, set to 1 when occupied.
His enumerator does not store one occupancy word. It stores two, Horz and Vert; all occupied cells are Horz | Vert. The rest of this section uses a single occupancy mask, which is enough to see the operations.
Let's adapt what I learnt about bitboards from his project (to be clear, he does not invent bitboards) to JavaScript :
. . B . . .
. . B . . .
A A B . . .
. . . . . .
. . . . . .
. . . . . .
Number the cells in row-major order, from left to right and top to bottom (this is his mapping: bit 0 is top-left):
0 1 2 3 4 5
6 7 8 9 10 11
12 13 14 15 16 17
18 19 20 21 22 23
24 25 26 27 28 29
30 31 32 33 34 35
const bit = cell => 1n << BigInt(cell)
const A = bit(12) | bit(13) // 12288n
const B = bit(2) | bit(8) | bit(14) // 16644n
const occupied = A | B // 28932n
In JavaScript, ordinary bitwise operators convert numbers to 32-bit integers (not enough for a 36-cell board, so these examples use `BigInt`). He used uint64_t in C++.
That gives you 36 flags in one number, with zeros for empty cells. For a cell inside the board, checking whether it is free takes a bitwise AND:
const empty = (occupied, cell) => (occupied & bit(cell)) === 0n
Each piece has a starting position, a size, and an orientation. Horizontal pieces use stride 1; vertical pieces use stride 6 (one whole row). Building the mask of a movable Rush Hour piece takes a loop of length 2 or 3:
function mask(piece) {
let m = 0n
for (let i = 0; i < piece.size; i++) {
m |= bit(piece.position + i * piece.stride)
}
return m
}
A horizontal step shifts the mask by 1 bit. A vertical step shifts it by 6 (stride), one whole row:
move right / east = mask << 1n
move left / west = mask >> 1n
move down / south = mask << 6n
move up / north = mask >> 6n
Those shifts alone don't make a move legal. A horizontal shift wraps: a bit that falls off the left edge of a row lands on the right edge of the row above, so you mask out the right column (& ~RightColumn). For a longer slide, every intermediate cell must be clear too. His move generator probes only the cells the piece would newly enter. The check below is the same idea on a full proposed mask:
function overlapsOthers(occupied, from, to) {
// Removing the moving piece from the overlap check matters because its old and new positions can share cells.
const others = occupied & ~from
return (others & to) !== 0n
}
// Once the slide has passed the boundary and path checks:
function apply(occupied, from, to) {
// XOR toggles the old and new masks: shared cells stay occupied, vacated cells turn off, and newly occupied cells turn on
return occupied ^ from ^ to
}
In our example, moving B one row south gives it cells 8, 14, and 20. A is still blocked at cell 14. Sliding B three rows south puts it at 20, 26, and 32, clearing A's path. The slide is legal because each position along the way is clear.
The operation that paid for bitboards in his enumerator is not "is this cell empty?" : while placing billions of candidate boards, he skips any state that is not the canonical member of its cluster. If any piece can still move up or left, a smaller (Horz, Vert) pair exists:
(((Horz >> 1) & ~RightColumn) | (Vert >> 6)) & ~All
If that is nonzero, the enumerator continues.
Very interesting ? Yeah I was fascinated too, but actually my crew of agents told me not to use this representation.

Actually it's a sensible engineering choice: Fogleman was enumerating billions of states, while for me (us) a simpler representation was enough to get it working (and JavaScript would need BigInt or multiple masks for a bitboard anyway).
His own Go solver already uses a boolean occupancy array and scans along each piece until it hits an obstacle.
I also actually benchmarked it, but I'm not confident that the benchmarking was coded and precise enough, so I just accepted the suggestion from the agents.

For Dungeotto, I kept the 36-cell array. To find legal moves, it builds a 36-cell occupancy array: each cell holds a piece index, or -1 if empty. Then it scans ahead of each piece until it hits an obstacle or the edge.
Even on the harder puzzles, this solution stayed within the latency budget, and occupancy checks accounted for little of that time. Most went into managing the search and tracking visited states.
Simple as fuck, but I still wanted to share bitboards in this devlog (so (maybe) you learnt something more).
Graphics
I bought Rumblecade's temple tileset, which already had a consistent style.

Grok helped generate a few additional graphics, including the mana symbol and background elements.
As I said before, the live tile picker at the ?edit endpoint made choosing the graphics much easier. I could try a floor or change a wall face and immediately see it in the room.
Vibecoded soundtrack
For music, I found TidalCycles. TidalCycles is a Haskell-based language for composing patterns: apparently studying Haskell at university was preparation for making a sarcophagus game.
Strudel as a live coding environment was very useful, and at the end it was something like this :
setcpm(92 / 4)
$: note("<[d2,a2] [d2,a2] [bb1,f2] [c2,g2] [eb2,bb2] [d2,a2] [c2,g2] [d2,a2]>")
.sound("sawtooth")
.lpf(sine.range(260, 520).slow(8))
.lpq(.16)
.attack(.25)
.release(1.1)
.gain(.18)
.room(.42)
.size(.78)
$: note("d4 ~ f4 eb4 a4 ~ f4 ~ d4 ~ eb4 ~ c4 ~ a3 ~")
.sound("triangle")
.decay(.8)
.sustain(0)
.gain(".14 .09 .12 .1")
.pan(sine.slow(3))
.room(.2)
$: sound("bd ~ [~ bd] ~")
.gain(".18 .12")
.lpf(720)
$: note("[d5 ~ ~ a5 ~ eb6 ~ ~]/2")
.sound("sine")
.decay(.28)
.sustain(0)
.gain(.055)
.room(.55)
.size(.86)
Me happy while vibecoding music:

Even Sound FX vibecoded too
For sound effects, I first searched online, but I couldn't find a suitable asset pack. I had a lot of different references from YouTube videos and didn't know enough about copyright to tell what I could use.
Then I started wondering how a sound is made, in particular if something like TidalCycles existed for just sounds.
I actually found a couple of tools used by a lot of indie gamedevs :
But I wasn't fully satisfied. So ... as with the rest of this project :

I gave Grok multiple examples, forced it to use spectrum analysis, and described the attack and decay.
It turns out that LLMs with custom JavaScript scripts running in Node.js let me have the desired sounds from oscillators, seeded noise and filters, using AI agents.
In particular, read this:
source: x.com/maxxrubin_
I wanted dry stone, brittle clay, and the weight of something heavy moving against a rail. I was genuinely surprised by how useful AI could be at identifying the ingredients of sounds.
The Anatomy of Fun
The game keeps 103,023 boards from Fogleman's 2,577,412, grouped by solution length, walls, and blocks. I also tagged patterns like two pieces blocking the exit, or a blocker that needs another piece moved first.
The run is a 24-chamber crowding cycle: sparse at the start, then a packed peak. After 24 chambers a lava-safe vase shop appears, the temple changes color, crowding resets, and lava keeps climbing. Mana is the currency for oracle hints.
I wanted some continuity too: when it is possible, a board is followed by a variation with its other pieces slid. Every changed board is solved again before it ships, so something you figured out in one room can help in the next.
Clears pay 1–3 mana, plus one extra for an efficient solve without hints. An oracle (the hint) costs 3, and ordinary rewards stop at 24. At the shop you keep the mana or bet it on a vase that loses, returns, or doubles.
I ran an offline simulator with 6 scripted profiles. They all know the solution, but they pause, cluster wrong moves (trying to simulate the way a human starts to fail, not just random fails), backtrack, and bet differently.
This partially helped to tune the gameplay a minimum ... obviously ideally I would have used real players.
Ok but something feels off. How to make moving blocks engaging?
Here is the recipe for my AI slaves :
<role> You are an expert game-feel designer. Your goal is to find the smallest changes that produce the largest increase in moment-to-moment fun. </role>
<principles> - Improve the decisions players make most often. Add meaningful trade-offs; strong choices can exist, but should not be immediately obvious. - Increase impact and control. Important actions should produce clear visual, audio, animation, camera, or mechanical reactions. - Tune challenge between boredom and frustration. - Remove downtime. Test gameplay speeds at +10%, +25%, and +50% where appropriate. - Prefer dynamic, directional, reactive effects over static ones. - Use camera movement, hit-stop, shake, recoil, particles, and sound to reinforce important actions without hurting readability. - Improve visual cohesion cheaply through curated palettes, contrast, and clearer hierarchy. - Reinforce the player's core fantasy through mechanics and feedback. - Prefer tuning and reuse over new systems, assets, or content. </principles>
<task> Analyze the game below and identify the highest-impact, lowest-effort ways to make it feel better.
Prioritize changes by:
1. Frequency — how often players experience it.
2. Impact — how much more satisfying it becomes.
3. Effort — how cheaply it can be implemented.
Be concrete. Give values, timings, intensities, or experiments when possible.
</task>
<output> Return: 1. **Diagnosis** — the main reasons the game currently feels weak. 2. **Top 5 Changes** — ranked by impact/effort. 3. **Micro-Decisions** — frequent decisions that could become more interesting. 4. **Game Feel** — specific improvements to movement, impacts, VFX, audio, animation, and camera. 5. **Speed Tests** — what to test faster and by how much. 6. **Quick Wins** — changes achievable with minimal implementation.
Do not recommend large new features unless they clearly outperform simpler tuning.
</output>
The results are actually good. Before:

After :

Extras: multiplayer and a dual-screen handheld console
Later, I added Trystero for peer-to-peer multiplayer. Both players descend the same seeded temple in split-screen, with independent runs.
Trystero uses WebRTC for the connection and Nostr relays to help the peers find each other. The game also supports configured TURN relays for networks where a direct connection won't work.
The browser build also has a companion HUD for a dual-screen handheld such as the AYN Thor. The temple runs in one browser window, while opening a second window shows your score, mana, pause and the oracle.

The two windows exchange state and commands through postMessage and BroadcastChannel. This is local communication between browser windows on the same origin, separate from the Trystero connection between players.