a daily home-price guessing game. real us homes, hidden list prices, five tries each.
▶ play at dillow.homes · what’s new · run it locally
Every day there are a few new homes. Study the photos, the facts and the map, then guess what each one is listed for.
- 🎯 Five tries per home. Each guess says higher or lower and how warm you are: 🟥 🟧 🟨 🟩 ✅. Land within 0.1% to nail it.
- 🔓 Clues unlock as you go. Lot size and list date after one guess, the listing description after two, the full feature list after three.
- 🎚️ The slider narrows to the range your guesses have proven.
- 🏆 Per-home leaderboards, ranked by closest guess, with fewest tries as the tie-break. Plus a daily streak and personal stats.
- 👯 Challenge rooms. Share a link and play bonus rounds against friends.
- 📍 Local mode. An endless run of homes near you.
- 🗺️ A real map (Leaflet + OpenStreetMap), a full photo gallery, and a link to the real listing once you finish.
No accounts. Your id is an anonymous UUID in localStorage.
- Next.js 16 (App Router) · React 19 · TypeScript (strict) · Tailwind CSS v4
- Upstash Redis for game state, leaderboards and rooms (optional locally)
- Leaflet maps,
next/ogfor the link-preview images - Hosted on Vercel. Listing data from realtor.com via the realtor16 RapidAPI wrapper, kept in a private Vercel Blob store and pulled in at build time, never committed (read Data source and terms)
Needs Node 20.9+ and npm.
git clone https://github.com/awlevin/dillow.git
cd dillow
npm install
npm run dev # http://localhost:3000No environment variables needed. With no credentials you play a small,
clearly fake sample dataset (src/data/sample/): 30 made-up homes in five
cities, with illustrated placeholder photos. Every game mode works on it. With
no Redis, game state lives in server memory (lost on restart) and the
leaderboards, crowd stats and challenge rooms switch off.
To turn those on, copy the template and add Upstash credentials:
cp .env.example .env.localThe live game's real homes are not in this repo. See The dataset.
Before you open a PR:
npm run lint
npm run typecheck
npm run buildCI runs the same three on every pull request.
- Data source and terms ← read before forking
- The dataset
- The request budget
- Architecture
- Environment variables
- Common workflows
- Conventions and gotchas
- Contributing, security, license
Read this before you fork this repo, and before you point it at any data source of your own.
The listing content — addresses, prices, descriptions, and photo URLs — comes
from realtor.com, reached through realtor16, a third-party
community wrapper on RapidAPI. realtor16 is not an official realtor.com API
and Move, Inc. does not license it. The game serves photos straight from
realtor.com's rdcpix CDN (hotlinked, not copied into this repo).
What that means in practice:
- The listing content is not in this repo, and never will be. Listing
descriptions are written by listing agents and brokers and are their
copyright; the photos are the copyright of whoever shot them. None of it is
the maintainer's to publish or relicense. The live game's dataset lives in
private storage (see The dataset); this repo ships only a
fake sample. The one exception is the README art in
docs/: screenshots of a home that was already played as a past daily, showing its photos, city, basic facts and revealed price, as players saw them. - The MIT license in LICENSE covers the source code and the
sample dataset, not the listing photos in the
docs/screenshots. If you fork this to run with real homes, build the dataset from a source you have rights to. - The legitimate path is IDX. A licensed broker or agent signs an IDX
agreement with their MLS, which gets you RESO Web API credentials and a
photo-bearing feed you are actually permitted to display — subject to that
MLS's attribution and display rules.
src/lib/providers/simplyrets.tsis already a RESO-shaped adapter, so that swap is small.RESEARCH.mdhas the original survey of the options.
Dillow is an independent hobby project. It is not affiliated with, endorsed by or sponsored by Zillow, realtor.com, Move, Inc. or any MLS. "View on Zillow" and "realtor.com" links are plain outbound links.
If you are the copyright holder of any listing content shown here and want it removed, open an issue or contact @awlevin and it will be taken down.
The single most important architectural constraint. realtor16 runs on a small monthly request quota, so the whole design is built to spend as few requests as possible:
- Gameplay spends zero upstream requests.
/api/daily,/api/attempt,/api/leaderboard,/api/localand friends read only the build-time snapshot insrc/data/plus Redis. - Requests are spent only by explicit, manual actions:
POST /api/refresh— rebuild the pool. One request per city (~12).POST /api/enrich?days=N— full galleries for the next N daily homes. One request each.- The
/adminrotate, enrich and archive pool actions.
- Remaining quota shows up in the
x-ratelimit-requests-remainingresponse header from realtor16.
If you are about to write code that calls a provider on a request path, stop. Warm the data into Redis or the snapshot out of band instead.
Browser ──GET /api/daily───────► snapshot pick (+offset) (+Redis gallery) [0 provider calls]
──POST /api/attempt─────► score vs hidden price, Redis game state [0 provider calls]
──GET /api/leaderboard─► per-home all-time board from Redis [0 provider calls]
──GET /api/local───────► endless queue of homes near a lat/lon [0 provider calls]
Admin ──POST /api/admin───────► verify | rotate | enrich | archive | limit [<=1 call each]
Manual ──POST /api/refresh─────► realtor16 search x N cities -> pool.json [N calls, dev only]
Build ──scripts/data.mjs──────► private Blob store -> src/data/*.json [0 provider calls]
Manual ──POST /api/enrich──────► realtor16 details for upcoming dailies [N calls, dev only]
Everything normalises into one Property shape (src/lib/types.ts).
Providers live in src/lib/providers/ and are chosen by PROPERTY_PROVIDER:
realtor16.ts(production) —/search/forsaleper city for the pool,/property/detailsfor a full gallery and rich details.simplyrets.ts— the public SimplyRETS demo feed. No credentials needed, prices randomised. Also the template for a real RESO/IDX feed.
To add one, write a module returning Property[] and wire it into the switch in
providers/index.ts.
The app imports two JSON files statically, so they are baked into the server bundle at build time and gameplay never waits on storage:
src/data/pool.json— the playable homes, imported bysrc/lib/game.ts. This is also the answer key: the prices and, through the deterministic pick, every future daily home.src/data/enrichment.json— the cold store of full photo galleries and rich details, exported from Redis byscripts/export-enrichment.mjs, so the app still has complete data if Redis is wiped or the realtor16 subscription lapses.
Neither file is committed (.gitignore covers src/data/*). The real pair
lives in a private Vercel Blob store (dillow-data) connected to the
Vercel project. scripts/data.mjs prepare runs before dev, build and
typecheck and puts the right pair in place:
| Where | What you get |
|---|---|
| Vercel builds | The real data from the store. The build fails if it cannot be fetched, so production can never quietly ship the sample. |
| Local, no credentials (contributors, CI) | A copy of the fake sample in src/data/sample/. |
Local, with BLOB_READ_WRITE_TOKEN |
The real data, pulled once. Local real data is never overwritten automatically; npm run data:pull refreshes it. |
Set DILLOW_DATA_SOURCE=sample to force the sample anywhere (for example a
fork deployed to its own Vercel project), or DILLOW_DATA_SOURCE=blob to
require the store. npm run data:status shows which dataset is active.
Why build time, and why Blob: the game reads the pool synchronously all over
src/lib/game.ts, so baking it into the bundle keeps that design and adds no
runtime dependency or latency. A private Blob store is Vercel-native: its
token is injected into every environment of the project, it is not tied to a
Git repository, and anonymous reads are refused.
Read order at runtime is Redis → enrichment.json → realtor16
(src/lib/property-payload.ts).
Vercel's filesystem is read-only at runtime.
/api/refreshand/api/enrichwritepool.jsonwithfs.writeFile, so they only work in local dev. The runtime path for new data is Redis.
getDailyProperty(dayKey(), offset) => pool[(hash(dayKey) + offset) % pool.length]dayKey() is the game day in America/New_York; hash is FNV-1a. Everyone
worldwide gets the same homes on the same day. offset lives in Redis and lets
an admin rotate the day's pick.
The algorithm is public, but the pool is not, so the code alone does not give away any answer. See SECURITY.md for what is and is not in scope.
/api/attempt is the heart of it. It scores against the hidden price, persists
per-player state in Redis, enforces the attempt cap server-side, and returns the
reveal (price, address, listing link) only once the game on that home is over.
toClue() strips price and address from everything served before then.
Coordinates are sent — the map is a clue, not a spoiler.
MAX_ATTEMPTS is 5 and the win threshold is 0.1% (src/lib/feedback.ts).
Scoring is log-error based so a $5M estate and a $200k condo are judged on the
same curve. Score is internal — it orders leaderboards and is never shown.
Provisioned through the Vercel Marketplace Upstash integration, which injects
KV_REST_API_URL / KV_REST_API_TOKEN (UPSTASH_REDIS_REST_* also accepted).
Everything degrades gracefully when Redis is absent: game state falls back to
process memory and the leaderboard, crowd stats and rooms turn off.
| Key | Type | Purpose |
|---|---|---|
plb:{propertyId} |
sorted set | All-time board for one home |
plbmeta:{propertyId} |
hash | Display details per entry |
game:{date}:{propertyId}:{id} |
string | Per-player state; enforces attempts |
offset:{date} |
int | Admin rotation offset |
gallery:v2:{id} / details:v2:{id} |
string | Warmed listing data |
pstats:{id} |
hash | Crowd valuation stats for a home |
room:{id} and room:{id}:* |
mixed | Challenge rooms |
Photos come from realtor.com's rdcpix CDN, which encodes the size in the
filename. A custom next/image loader (src/lib/rdcpix.ts) rewrites the token
to resize at the CDN, bypassing Vercel's billed image optimisation. Other
URLs pass through untouched — the sample dataset uses the SVG illustrations in
public/sample/.
No accounts. Identity is an anonymous crypto.randomUUID() in localStorage
(src/app/_lib/client-storage.ts): dillow:id, dillow:name,
dillow:game:{date}, dillow:streak, dillow:stats, dillow:homes,
dillow:rooms, dillow:flags. Nothing identifying is collected server-side
beyond a display name a player types themselves.
Names only — see .env.example for the template, and never
commit real values.
| Variable | Required | Where to get it |
|---|---|---|
BLOB_READ_WRITE_TOKEN |
for the real dataset | Injected by Vercel when the private dillow-data Blob store is connected to the project. Locally: vercel env pull .env.local. Unset means the sample dataset |
DILLOW_DATA_SOURCE |
no | sample or blob to override the dataset choice (see The dataset) |
PROPERTY_PROVIDER |
no | realtor16 or simplyrets (default simplyrets) |
REALTOR_16_RAPID_API_KEY |
for realtor16 | A RapidAPI account subscribed to realtor16; Basic (free) is enough |
REALTOR16_LOCATIONS |
no | Your own comma-separated City, ST list |
REALTOR16_LIMIT |
no | Listings per city (default 50) |
SIMPLYRETS_BASE_URL / _USER / _PASS |
no | Your SimplyRETS account or RESO gateway; defaults to the public demo |
KV_REST_API_URL / KV_REST_API_TOKEN |
for leaderboard, rooms, galleries | Vercel Marketplace → Upstash integration (auto-injected), or the Upstash console |
ADMIN_PASSWORD |
yes, for /admin |
Generate one: openssl rand -hex 24. No default — if it is unset, /api/admin returns 503 for every request |
REFRESH_TOKEN |
production only | Any long random string. Required as ?token= on /api/refresh and /api/enrich; both refuse everything when unset |
NEXT_PUBLIC_SITE_URL |
no | Canonical origin for link previews (default https://dillow.homes) |
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY |
no | Google Cloud browser key with the Maps Embed API. Ships to the browser — restrict it by HTTP referrer. Unset uses the keyless OSM/Leaflet map |
.env.local is git-ignored. Note that vercel env pull overwrites it with only
the Vercel project's variables, so keep a copy of anything local-only. It also
brings in the production Redis credentials: a local game then writes to the
real leaderboards.
# Rebuild the whole pool — one request per city. DEV ONLY.
curl -X POST http://localhost:3000/api/refresh
# Full galleries for the next N daily homes. DEV ONLY.
curl -X POST "http://localhost:3000/api/enrich?days=10"
# Export the Redis enrichment archive into src/data/enrichment.json.
node scripts/export-enrichment.mjs
# Upload both files to the private store (keeps a timestamped archive copy),
# then redeploy so the build picks them up. Never commit them.
npm run data:push
vercel redeploy dillow.homes --target production/admin, gated on ADMIN_PASSWORD. The password is checked server-side and
never ships to the client. Actions:
- rotate — bump
offset:{date}, clear the day's in-progress games, and warm the new home's gallery. - enrich — pull the current home's full gallery and details into Redis.
- archive pool — bulk-warm every home in the pool, one upstream request each, so the app survives a lapsed subscription.
- limit — toggle the classic three-homes-a-day cap.
docs/banner.png, docs/screenshots.png and docs/social-preview.png (the
GitHub social preview) come from scripts/readme-art/. The header of
capture.mjs has the steps: a local build on the real data, no Redis, its
clock set to a past game day, played in headless Chrome. Use only a home that
was already a past daily and is not due again soon, and keep street addresses
out of frame.
Vercel builds and deploys main. Each build pulls the dataset from the
private store and bakes it in, so a data change ships with the next deploy.
- Never call a provider on a request path. Budget. (Worth repeating.)
getDailyPropertyreads the snapshot synchronously. The route layer applies the Redis offset and gallery override.- Do not write to
src/data/at runtime — read-only filesystem on Vercel. - Never commit real listing data.
src/data/*is git-ignored except the sample, and CI fails if anything else undersrc/data/is tracked. - Points are internal. Compute them for ranking; never render a raw score.
- Watch the
react-hooks/set-state-in-effectrule (Next 16): wraplocalStorage-reading effects that callsetStatein an async IIFE. - The player-facing changelog is
src/content/changelog.md, rendered at/changelog. Keep it current for player-visible changes only.
- Contributing: CONTRIBUTING.md
- Security: SECURITY.md — report privately, never as an issue
- License: MIT © Aaron Levin, for the source code and the
sample dataset. The live game's listing data is not part of this repo, and
the listing photos in the
docs/screenshots are not covered — see Data source and terms.