💎 Crystal Cascade — Project Story
From a simple game, there could be an enormous level of Joy. We developed this game with the aim of simplicity and faster reaction and extensive decision making.
How a 3D match-three puzzle game found its home inside Reddit.
Inspiration
There were two threads that came together to make Crystal Cascade happen.
The first was a personal one. As a software engineer, I spend most of my day thinking in abstractions — system design, data flows, debugging logic. But coding is a slow, deliberate kind of thinking. I wanted something that trained a different side of my brain: faster decision-making, sharper pattern recognition, and quicker reactions under pressure. I started playing games like Candy Crush Saga during breaks — not casually, but intentionally, using them as a daily exercise to sharpen the kind of split-second spatial reasoning and critical thinking that makes you a better engineer over time. Match-three games turned out to be surprisingly effective at this. The constant need to scan the board, anticipate chain reactions three moves ahead, and act quickly before the window closes is a genuine cognitive workout.
That experience gave me deep respect for what great match-three design actually achieves. It's not just entertainment — it's a focused mental training loop disguised as play.
The second thread was a frustration with Reddit. Reddit is where millions of people go to share ideas, debate topics, and build communities around the things they love — including games. Yet when you visit a gaming subreddit, the most interactive thing you can do is upvote a screenshot or leave a comment.
I kept asking: what if the post itself was the game?
Match-three games sit at an elegant intersection of accessibility and depth. The rules are immediately understood by anyone — align three gems, watch them disappear — but the underlying strategy around chain reactions, combo multipliers, and special power-up interactions creates a surprisingly rich decision space.
The launch of Reddit's Devvit platform changed the equation. Suddenly, it was possible to embed a fully interactive experience directly inside a Reddit post — the same post your friends are commenting on, the same feed you scroll every morning. That proximity to community felt like a design constraint worth building around, not escaping from.
The name Crystal Cascade captures the feeling I was chasing: the satisfying visual and audio cascade of chain reactions, the sense that each move could set off something much larger than itself — and the same feeling of mental clarity that comes from locking in on a pattern and executing it fast.
What it does
Crystal Cascade is a premium 3D match-three puzzle game that lives natively inside Reddit posts via Devvit's custom post webview.
Players look down at a glowing 8×8 grid of coloured gems rendered in real-time 3D using WebGL and Three.js. The goal on each level is to clear all the stone blocks hidden beneath the gem layer within a fixed number of moves.
Matching gems causes them to shatter and fall, damaging any blocks underneath. But the real depth comes from the special crystal system:
| Match Pattern | Crystal Created | Effect |
|---|---|---|
| 4 in a row/column | Striped Crystal | Clears entire row or column |
| L-shape or T-shape | Wrapped Crystal | Blasts a 3×3 area |
| 5 in a line | Rainbow Crystal | Removes every gem of one colour |
| 2×2 square | Square Blast | Area explosion around the match |
When two specials are swapped together, they combine into escalating super-combos — a Rainbow + Striped clears every gem of a colour in a full row, a double Rainbow wipes the entire board.
The game ships with 5 hand-tuned levels of increasing difficulty, followed by an infinite procedural mode where levels are generated on the fly. An AI Auto-Play mode demonstrates optimal play patterns and provides an accessibility path for players who want to watch rather than play.
All sound is synthesised live using the Web Audio API — no audio files, no bandwidth cost, just pure procedurally generated feedback tuned to match game events.
How we built it
The project went through two distinct phases:
Phase 1 — Standalone prototype
The first version was a standard Vite + React application. It established the core game architecture:
GameEngine.tsx— a 1 500-line Three.js rendering loop managing the board state, gem meshes, tween animations, raycasting for touch/click input, particle effects, and the match resolution pipeline.HUD.tsx— a React overlay tracking moves, objectives, and level progress.Overlay.tsx— animated modal cards for level start, win, and lose states.audio.ts— a Web Audio API synthesiser class that generates pentatonic-scale tones, pitched higher with each combo multiplier.
The board state is modelled as a flat typed array of GemCell | BlockCell | null, making match detection and cascade resolution straightforward to reason about. Match-finding runs in $O(n^2)$ time over the board:
$$ \text{matches} = \bigcup_{r=0}^{N-1} \text{rowRuns}(r) \;\cup\; \bigcup_{c=0}^{N-1} \text{colRuns}(c) \;\cup\; \text{squares}_{2\times2} $$
where $N = 8$ is the board dimension and each run is a maximal contiguous sequence of identical gem types of length $\geq 3$.
Special crystal spawning position is determined by:
$$ \text{spawnIndex} = \begin{cases} \text{lastSwap position} & \text{if lastSwap} \in \text{run} \ \lfloor \frac{\text{runStart} + \text{runEnd}}{2} \rfloor & \text{otherwise} \end{cases} $$
Phase 2 — Devvit migration
Once the game logic was solid, we migrated the entire codebase into the Devvit scaffold under crystal-cascade/src/client/. This required:
- Replacing Phaser — the default Devvit template ships with Phaser; we removed all Phaser scenes and replaced
game.tswithgame.tsx, mounting our React application into the webview root. - CSS consolidation — Vite's separate
index.cssandApp.csshad to merge into a singlegame.cssthat the Devvit bundler could resolve. - TypeScript strictness — Devvit's
tsconfig.base.jsonenablesexactOptionalPropertyTypes, which caught several subtle typing errors in our toast notification system whereicon?: Tcould not be spread alongsideundefinedvalues without explicit conditional handling. - JSX support — the client
tsconfigneeded"jsx": "react-jsx"added, which the Phaser template doesn't include.
Icon system
To eliminate emojis from all game UI (which render inconsistently across platforms and operating systems), we built a lightweight Icon.tsx component:
// 11 named SVG icons, zero external dependencies
<Icon name="zap" size={16} /> // renders inline SVG
Every showToast() call now carries a named icon alongside the text, rendered as a crisp inline SVG that looks identical across every device.
Level design & winnability
Each level was verified against a custom check_levels.mjs script that runs Monte Carlo simulations of random play, confirming that every level has a positive expected win rate before shipping:
$$ P(\text{win}) = \frac{\text{simulated wins}}{N_{\text{trials}}} > 0 \quad \forall \text{ levels} $$
Challenges we ran into
1. The dead-board problem
The most stubborn bug: after a cascade of matches, the board could reach a state where no valid swap produced a match. The naive fix (shuffle immediately) caused jarring visual jumps. The real fix required implementing checkMatchesOnly() — a fast pass that detects valid moves without triggering effects — and ensuring the shuffle preserved all existing special crystals in place while only rearranging normal gems.
A subtler version of the same problem appeared in the shuffle validator itself: the original checkMatchesOnly() didn't detect 2×2 square matches, so after shuffling it would sometimes declare the board dead even though a square match was sitting right there. Fixing this required unifying the match detection logic between findMatches() (gameplay) and checkMatchesOnly() (shuffle validation).
2. Special crystal near-miss seeding
Early playtesting revealed that Striped and Rainbow crystals rarely appeared naturally, because the random board initialiser almost never seeds the near-miss patterns (4 gems of one colour with one gap) that make them easy to earn in the first few moves. We implemented plantNearMisses() — a post-initialisation pass that deliberately inserts A A _ A row/column patterns into the board without creating immediate matches. This significantly improved the frequency of special crystal creation in normal play.
3. Devvit's exactOptionalPropertyTypes
When migrating to Devvit's strict TypeScript config, the pattern { id, text, icon } where icon could be undefined caused the type error:
Type 'undefined' is not assignable to type 'IconName'
Because exactOptionalPropertyTypes treats prop?: T as strictly T, not T | undefined. The fix was to conditionally spread the property:
const item: ToastItem = icon !== undefined
? { id, text, icon }
: { id, text };
4. Responsive layout in Reddit's webview
Reddit embeds the webview at whatever dimensions the native app decides — which varies wildly between a Reddit desktop sidebar, a mobile app full-screen, and a tablet split view. Getting the HUD, 3D canvas, and objectives footer to coexist across all these sizes required three distinct CSS breakpoints: ≤ 720px (grid HUD), ≤ 400px (compact phones), and max-height: 500px landscape (flat row HUD with hidden secondary elements).
Accomplishments that we're proud of
Zero audio files. Every sound in the game — match tones, cascade chimes, combo escalation — is synthesised in real-time from pure Web Audio API oscillators. The pentatonic scale ensures it always sounds pleasant regardless of how fast the player triggers sounds.
A genuinely fair shuffle. The board shuffle algorithm guarantees that after reshuffling, at least one valid match exists and the board is not in a state that would immediately auto-match. This required iterative re-shuffling with validation, capped at 200 attempts before falling back to a deterministic arrangement.
Type safety under extreme strictness. Devvit's
tsconfigis configured with every strict flag enabled —noUncheckedIndexedAccess,exactOptionalPropertyTypes,noUnusedLocals,noUnusedParameters. We achieved zero TypeScript errors under this configuration across ~3 000 lines of game code.A premium visual experience in a Reddit post. The glassmorphism HUD cards, gradient title typography, and smooth 60fps gem animations look as polished as a native mobile app — inside a Reddit post.
What we learned
WebGL in a webview is fast, but you have to manage the context manually. Three.js's WebGLRenderer doesn't know about React's lifecycle, so every time playEvent triggered a board reset we had to explicitly dispose all geometries, materials, and the renderer itself before re-creating them. Memory leaks from un-disposed Three.js resources were invisible at first but caused the game to slow down after several level restarts.
Devvit's bundler is not Vite. Even though the project uses Vite under the hood, Devvit wraps it with its own config that has strong opinions about code splitting, sourcemap naming, and module resolution. Several patterns that work in a normal Vite app fail silently or with opaque errors in the Devvit build pipeline — reading the actual bundler output carefully was essential.
Match-three game design is harder than it looks. The rules of match-three are simple, but designing levels that feel fair, satisfying, and progressively challenging without a level editor or playtest team requires a surprising amount of mathematical reasoning about board state probabilities, expected gem distributions, and combo likelihood under different move constraints.
Emoji are not portable. What renders as a glowing diamond on macOS is a grey box on certain Android versions and a different shape entirely on some Linux terminals. Replacing every emoji in the game UI with inline SVG icons was a net improvement in every measurable way — consistency, size, accessibility, and style control.
What's next for Crystal Cascade
Near-term
- Leaderboard — store high scores per-level in Devvit's Redis KV store, surfaced on the splash screen as a community scoreboard
- Daily Challenge — one shared board configuration per day, seeded from the current date, so the entire Reddit community plays the same puzzle and can compare results in the comments
Medium-term
- Subreddit skins themed gem colour palettes that automatically apply based on which subreddit the post lives in (e.g. space-themed gems for r/space, nature colours for r/EarthPorn)
- Hints system a subtle glow effect on one valid swap when the player hasn't moved in 10 seconds, helping new players learn without breaking immersion
- Accessibility mode shape-coded gems (circle, square, triangle, diamond, hexagon, star) as an alternative to colour-only differentiation
Long-term
- Cooperative mode two Reddit users play the same board simultaneously, each seeing the other's cursor position, racing to clear blocks together
- Community-created levels a level editor embedded in a Devvit moderator panel, letting subreddit mods publish custom Crystal Cascade levels for their community
Every cascade starts with a single swap. So does every great project.
Repository: EmmaTheDev01/crystal_cascade Platform: Reddit Devvit Stack: React 18 · Three.js · TypeScript · Web Audio API · Hono · Vanilla CSS
Built With
- hono
- react18
- three.js
- typescript
- vanillacss
- webaudioapi
Log in or sign up for Devpost to join the conversation.