How I built a persistent multiplayer hex-grid game that lives inside a Reddit post — with Devvit Web, Phaser 3, React, and Redis, and shipped it in time for Reddit's "Games with a Hook" Hackathon.
The idea
Reddit communities already fight over everything. TideShift gives that energy a board: a persistent hex map inside a Reddit post, where every player belongs to a faction. Once a day you solve a quick challenge — a word unscramble, a math problem, a riddle — and claim tiles for your faction. The map evolves for weeks. When one faction controls 60% of the map, the tide turns: the season ends, a victory comment is posted, and the map resets.
The retention loop is the whole design:
load → see the living map → solve today's challenge → watch your tiles burst in → come back tomorrow
Play it: the app is listed at developers.reddit.com/apps/tideshift1, with a demo at r/tideshift1_dev. Source: github.com/harishkotra/tideshift.
Architecture
Devvit Web apps have three parts: a static webview client, a Node server that Reddit hosts for you, and a devvit.json manifest that wires them to Reddit surfaces (posts, menus, cron). You get Redis and the Reddit API as built-ins — zero infrastructure of your own.
┌──────────────────────────── Reddit post─────────────────────────┐
│ ┌───────────────────────── Webview (client) ──────────────────┐│
│ │ React 18 + Tailwind (HUD, views, tutorial, legend, forms) ││
│ │ Phaser 3 canvas (hex map, particles, camera, screen shake) ││
│ │ WebAudio synth (all SFX generated in code — zero assets) ││
│ └──────────────────────────────┬─────────────────────────────-┘│
│ fetch('/api/…') │
└─────────────────────────────────┼────────────────────────────---┘
▼
┌───────────────── Node server (Hono) ─────────────────┐
│ GET /api/init full game state │
│ POST /api/submit answer → claim tiles → win? │
│ GET /api/leaderboard top 10 │
│ GET /api/tile/:index tile owner info │
│ POST /api/suggest community challenge intake │
│ POST /internal/menu/* create post / reset (mods) │
│ POST /internal/scheduler/daily-recap (cron 0 0 *) │
└──────────────┬───────────────────┬───────────────────┘
▼ ▼
┌────────────┐ ┌──────────────┐
│ Redis │ │ Reddit API │
│ grid, players, │ comments, │
│ leaderboard (zset)│ post creation│
│ challenge, season │ │
└────────────┘ └──────────────┘
The devvit.json manifest is short and declarative — menu items and cron jobs are just HTTP endpoints:
{
"name": "tideshift1",
"permissions": { "redis": true, "reddit": { "enable": true, "scope": "moderator" } },
"post": { "dir": "dist/client", "entrypoints": { "default": { "entry": "index.html", "height": "tall" } } },
"server": { "dir": "dist/server", "entry": "index.cjs" },
"scheduler": { "tasks": { "daily-recap": { "endpoint": "/internal/scheduler/daily-recap", "cron": "0 0 * * *" } } }
}
A shared src/shared/ module (types, constants, game rules) is imported by both the client and the server, so the dev-mode mock server and the real server can never drift apart.
The daily challenge: deterministic by design
Every player must see the same challenge on the same day, and the server should be stateless about it. The trick: seed the challenge from the date string.
export function generateDailyChallenge(dateStr: string, community: CommunityChallenge[] = []): Challenge {
const seed = dateStr.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
// Every other day, feature a player-written challenge with author credit
if (community.length > 0 && seed % 2 === 0) {
const item = community[seed % community.length];
return { id: `challenge-${dateStr}`, date: dateStr, type: 'community',
prompt: item.prompt, answer: item.answer.trim().toLowerCase(),
hint: item.hint, reward: { tiles: 3 }, author: item.author };
}
const types = ['word_unscramble', 'math', 'trivia'] as const;
const type = types[seed % types.length];
// …index into the hand-written pools by the same seed
}
Determinism eliminates a whole class of sync bugs: there's nothing to broadcast, no "challenge rotation job" to run, and Redis stores exactly one challenge document per day.
Anti-cheat falls out of the architecture: the answer never leaves the server. The /api/init handler strips it with a destructuring omit before responding:
const { answer: _answer, ...safeChallenge } = challenge;
return c.json({ type: 'GAME_STATE', payload: { grid, player, challenge: safeChallenge } });
Hex math: the part everyone underestimates
The map is a 20×14 flat-top hex grid stored as a flat array. Offset coordinates make storage trivial but neighbor math weird — the six neighbors differ depending on column parity:
export function getHexNeighbors(col: number, row: number): [number, number][] {
const dirs = col % 2 === 0
? [[-1, -1], [-1, 0], [0, -1], [0, 1], [1, -1], [1, 0]]
: [[-1, 0], [-1, 1], [0, -1], [0, 1], [1, 0], [1, 1]];
return dirs.map(([dc, dr]) => [col + dc, row + dr] as [number, number])
.filter(([c, r]) => c >= 0 && c < GRID_COLS && r >= 0 && r < GRID_ROWS);
}
Tile claiming uses this to make empires contiguous: claims prefer neutral tiles adjacent to territory you already own (Fisher–Yates shuffled for fairness), falling back to anywhere on the map. Contiguity matters more than it sounds — it's what makes the map readable. You glance at it and see empires, borders, and sieges.
On the rendering side, converting a tap back to a hex has to survive a zooming, panning camera. The mistake is doing your own math; the fix is one Phaser call:
private onPointerUp(pointer: Phaser.Input.Pointer) {
if (this.isDragging) return;
const world = this.cameras.main.getWorldPoint(pointer.x, pointer.y); // camera-aware
const hex = pixelToHex(world.x, world.y);
// …highlight + open the tile panel
}
Seasons without a mass migration
When a faction hits 60%, the shared grid resets — but player records live in per-user Redis keys. Scanning and rewriting every player on victory would be slow and racy. Instead, each player record carries a season stamp, and stale territory is cleared lazily on the player's next load:
if ((player.season ?? 1) !== season) {
player.ownedTiles = [];
player.tileCount = 0;
player.season = season;
}
The winning submit response carries the pre-reset grid so the client can still celebrate over the conquered map, while Redis already holds the fresh one. Nobody pays the reset cost except the moment they return — which, in a daily game, is exactly when you want them to see the blank map and land-rush it.
Sound with zero assets
All SFX are synthesized WebAudio — oscillators with envelope shaping, about 100 lines total. Claiming N tiles plays N rising blips, pitched up per tile:
export function sfxClaim(step = 0) {
play([{ freq: 440 * Math.pow(1.12, step), at: 0, dur: 0.14, type: 'triangle' }]);
}
No files to load, nothing to bundle, and the "claim arpeggio" scales naturally with reward size. Cheap juice — particle bursts, a 150ms camera shake, per-tile audio — did more for the daily-return feeling than any tuning of reward numbers.
Theming down to the canvas
Reddit renders webviews in the viewer's light or dark theme. Tailwind's dark: variants handle the DOM, but the Phaser canvas needs its own switch. A matchMedia('(prefers-color-scheme: dark)') listener drives both: React re-renders with the right classes, and the scene re-skins live — background color swap plus a redraw of all 280 tile graphics. Total cost of full dual-theme support: about an hour.
Lessons
- Determinism is a superpower for daily games. Seed from the date; delete the sync problem.
- Make user content the content. The community-challenge pool means the game refreshes itself, and the author credit ("challenge by u/…") is itself a retention hook.
- Lazy migration beats mass migration on key-value stores. Stamp records with an epoch; reconcile on read.
- The platform is the multiplayer stack. Redis + hosted endpoints + cron + identity, all built in — TideShift has no servers, no auth, no database of mine, and it's a persistent multiplayer game.
TideShift was built for Reddit's Games with a Hook Hackathon. Try it at r/tideshift1_dev, and the code is on GitHub.
Code & more: https://www.dailybuild.xyz/project/195-tideshift
Top comments (0)