DEV Community

Cat
Cat

Posted on

Impostor Hunt: a hide-and-seek game that only works if you touch grass

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass.

Every party game I know has the same failure mode: it keeps five people sitting in one room, staring at screens, for an hour. I wanted the opposite. Impostor Hunt is a five-player social deduction game — one impostor, four survivors — that you play by walking around your actual neighbourhood. You cannot win it sitting still. That is not a tagline; the rules actively punish staying put.

The game in one minute

Five people join a lobby from their phones with a four-character code. The server secretly picks one impostor and tells only that phone its role — the roster everyone else sees carries no roles at all.

  • Survivors are handed missions: walk a distance, get far from everyone, or get uncomfortably close to someone. Finishing one makes you invisible to the impostor for 45 seconds.
  • The impostor sees a 40 × 40 m box in front of them and nothing else. No dots, no names.
  • Stand still for five minutes and you are exposed: the impostor sees you wherever you are. Move again and you disappear.
  • Two players within 6 m are touching, and the server decides who is taken. The impostor catching a survivor is 10 points and that survivor is out. A survivor who tags the impostor while invisible — right after finishing a mission — wins the whole match on the spot.
  • It ends when the impostor catches everyone, when a survivor tags them, when the impostor quits, or at a 15-minute whistle. Then the winner is revealed and the result is written to a leaderboard.

Everyone is walking the same streets, half-missing each other, and only the server knows the whole picture.

Why it is a "Touch Grass" build

This is the part of the challenge I actually cared about: the screen has to be the shortest part of the experience.

  • Distance is the only currency. Missions are measured in metres, invisibility is earned by moving, and the one thing that exposes you is standing still. You cannot optimise this by tapping faster.
  • The map is not the game. A survivor's map shows only their own dot and an accuracy circle. The server sends survivors no other positions at all — just a pulse when someone comes within 10 m. You have to look up and find people with your eyes.
  • GPS is treated as what it is. Consumer fixes are 5–20 m at best outdoors and worse between buildings, so the server refuses any fix vaguer than 30 m, refuses a "jump" faster than walking speed, and refuses a fix older than 45 seconds. The game leans on that vagueness instead of pretending it is not there.
  • It installs like an app. A manifest and a hand-written service worker put it on the home screen and hold the shell when a phone loses signal — honestly scoped: the shell and the rules survive offline, a live match needs the server.

Demo

Bring four friends and five phones. It needs HTTPS, which browsers only give location on, so the deployment supplies it.

Live demo: https://grass-touch-one.vercel.app

One person creates a lobby, shares the four-character code, and the host starts once all five seats are filled.

Where the open model fits, and where it honestly does not

I am going to be blunt, because the most honest thing about this build is also its design: the open-weight model writes the words, and the rules engine decides everything that can win or lose the game.

Every mission has two parts. The kind of mission and the distance in metres are decided by a plain function, generateMission(), and by pure progress rules that the socket server and the test suite both run. Those are the parts that make a mission playable and measurable. The sentence the player reads on their phone is the only thing handed to a model — and it is an open-weight one: Groq's free tier serving llama-3.1-8b-instant, no card, no lock-in.

The model turns "walk 120 m from where you are" into something with a bit of character. It gets 1.5 seconds and then it is dropped. Its reply is clamped before it can ever reach a screen — collapsed to one line, cut at 180 characters, emptied if it is not text — and the swap is guarded by the mission's id, so a late reply can never overwrite the newer mission you are already reading. Remove GROQ_API_KEY and the game is byte-for-byte what it was before the AI existed; the test suite runs with no key on purpose and asserts that no network call happens without one.

So why does open matter here, in one sentence? The only non-deterministic, unattributable component in the entire system is one sentence of flavour text, and because it is an open-weight model behind a plain OpenAI-compatible HTTP call, I can swap it, self-host it, delete it, or reason precisely about what it can and cannot affect. A closed model as a required dependency would have been the opposite trade: more "AI", less game, and a piece of the experience I could neither reason about nor take away.

There is a second open angle that matters more for a location game: it is self-hostable. The rules engine, the socket server and the database are one Node process and one Postgres. Your location history lives in your own database, not someone else's. A free-tier blueprint deploys it in one click, and locally it runs behind a cloudflared tunnel so five real phones can join.

Under the hood

The stack is deliberately small — one repo, one port, no Redis, no PostGIS, no auth framework:

  • Next.js 16 + Socket.IO on a single port (server.ts), so the whole game is one deployable service: next build plus node server.ts, nothing else.
  • Postgres + Prisma 7 for accounts, sessions, matches and the leaderboard.
  • Accounts by hand: scrypt hashes with a per-user salt; sessions are rows holding a SHA-256 of a 32-byte token; httpOnly, sameSite=lax cookie. Login runs the hash even for a username that does not exist, so timing cannot enumerate accounts.
  • Leaflet + OpenStreetMap for the client map.

The rule that made all of it possible is that everything that decides a match runs on the server, and the decisions themselves are pure functions in one file that both the server and the tests import:

// Re-run the whole room after every fix: one new position can change
// several answers at once.
const exposed = exposedAt(track, now);        // 5 min standing still
const hidden  = track.invisibleUntil > now;   // 45 s mission reward
if (!hidden && (track.exposed || inVision(eye.fix, track.fix))) {
  seen.push({ id, name, lat, lng });          // impostor sees only this
}
Enter fullscreen mode Exit fullscreen mode

Identity comes from the session cookie on the socket handshake, never from anything the client claims, and a fix has to survive three server-side checks before it counts at all. The client is an input device and a screen — which is exactly what you want when the board is a few city blocks and the players are adversarial.

What I would do differently

  • The AI layer earned its place by being removable. If the model had generated the missions themselves, a slow or silly reply would have been a broken match instead of a flat line of flavour text. I would make that trade again.
  • Lobbies and running matches live in memory, which is why the deploy runs exactly one instance. Fine for five players on foot; it is the first thing I would move to Postgres if the game ever needed a second server.
  • There is no license on the repo yet. The code is public, but public is not the same as open. That is the first thing on my list this week.

Code

Impostor Hunt

Real-time, location-based social deduction for five phones. One player is the impostor, four are survivors. Survivors complete missions and stay moving; the impostor hunts them with a 40×40m vision cone. Survivors win by being the last ones standing, by tagging the impostor while invisible, or when the impostor quits; the impostor wins by catching everyone, or on points at the whistle.

Built in phases — see "Status" below for how far along it is.

Stack

Deliberately small: one Next.js app, one Postgres database, no Redis, no PostGIS, no auth framework. Mission wording can be handed to a free, open-weight model, but the game never requires it (phase 14).

Piece Choice
App + realtime Next.js 16 + Socket.IO on one port (server.ts)
Data Postgres + Prisma 7 (driver adapter @prisma/adapter-pg)
Accounts Hand-rolled sessions in Postgres; scrypt from node:crypto
UI Tailwind v4 with the palette in src/app/globals.css
…

Run it yourself:

pnpm install
cp .env.example .env
pnpm exec prisma dev -d      # a local Postgres, no install
pnpm exec prisma migrate dev
pnpm dev                     # http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

For phones, tunnel it — location only works over HTTPS:

cloudflared tunnel --url http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

pnpm check talks to the running server and reads the recorded rows back, asserting the realtime, account, lobby, position, catch, result and screen contracts. It fails loudly if a phase regresses, which is how I caught most of the bugs above before anyone else could.

If you build something like this

The community has already written up the pieces I leaned on. If you are heading the same way, these are worth your time:


Impostor Hunt: one impostor, four survivors, one neighbourhood. If you try it with friends, tell me what broke — the first fix always ends up in the README.

Top comments (0)