DEV Community

Cover image for Building TideShift: A Subreddit-Wide Territory War on Reddit's Developer Platform
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on

Building TideShift: A Subreddit-Wide Territory War on Reddit's Developer Platform

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 │              │
                └────────────┘      └──────────────┘
Enter fullscreen mode Exit fullscreen mode

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 * * *" } } }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 } });
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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' }]);
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Determinism is a superpower for daily games. Seed from the date; delete the sync problem.
  2. 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.
  3. Lazy migration beats mass migration on key-value stores. Stamp records with an epoch; reconcile on read.
  4. 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)