My haunted mirror possesses your reflection with open-weight AI. A player’s idea made it hunt you in the dark
This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine After trying my haunted mirror, a player sent me the most useful message I got all week: "Instead of looking at yourself, make the screen go black. Say it can't see you. Then jump out with a really loud

Hacktoberfest Open-Source AI Challenge: Ghost in the Machine Submission 👻🌼
This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine
After trying my haunted mirror, a player sent me the most useful message I got all week:
"Instead of looking at yourself, make the screen go black. Say it can't see you. Then jump out with a really loud noise."
They were right. If you can watch it coming, you're ready for it. So I built lights-out mode the same afternoon. The screen goes black. A flickering "I can't see you…" sits in the middle, and a voice whispers it in the dark. An open-weight face model keeps watching you in the dark, and when it decides, or the moment you blink, your own possessed face jumps out at you, 2.4× louder.
This post is about that loop: build, put it in front of a person, listen, fix, measure. It covers the ghosts I built with open models, everything that broke along the way, and what AI made possible for one developer in one week.
Lights-out mode. Demo face generated with SD-Turbo (not a real person); simulated camera and flinch.
🎃 Live: scare-tv.onrender.com. It works on any phone with a front camera. Choose Look 👁️ for the mirror or Lights out 🌑 for the ambush. At the end you get a polaroid of your own face half a second after the hit, with your jump-o-meter score and how you rank against everyone else.
Every demo face in this post and in the video was generated with SD-Turbo on my laptop. None of them is a real person, and the flinches in the GIFs are simulated. The real reactions are in the numbers section.
Ghost in the Machine, literally: a ghost that lives inside your camera feed. You look at the screen, and the screen looks back.
A laptop behind the TV, a webcam on top, and a friendly jack-o'-lantern on screen saying "Happy Halloween! Come closer… I have treats for you." People come closer, right in front of the camera. YOLO11n-pose (6 MB, open weights) checks every frame for a person, not just movement, so the cat and the curtains don't count. Then the TV becomes a mirror, and one of six things happens to their real reflection:
And 20 classic scares. Not everyone wants to point a camera at their face, so the TV and the phone also have 20 classic jump scares (a skull, a thousand smiles, something on the ceiling, a dead channel, a flatline…). The art and every sound are generated in code, with no samples and no copyrighted characters.
The same six possessions run inside a phone browser, on top of MediaPipe Face Mesh: 468 landmarks, open weights, about 10 MB, served with the page instead of from a CDN. Nothing to install. The server never receives a frame.
Cartoon ghosts are fun, but you always know they're drawings. So a second open model, SD-Turbo, imagines realistic horror portraits on my 8 GB MacBook Air. Then YOLO-pose grades every photo before it's allowed to scare anyone: it needs a clear head, visible eyes and shoulders below the nose. Photos that fail get imagined again, up to five times, and the rest are thrown away. The survivors get possessed and become a scare: darkness, a flashlight sweeping the room, then the whole face lunging at you.
Every scare, from every TV and every phone, lands in one PostgreSQL database on Render: one row per person, with their scare count, their highest jump level (0–10) and whether they ran out of frame. A Django admin lets me filter by phone or TV, by possession, by mode, by jump level.
This is the part I'm proudest of, and it isn't code.
Before: the mirror gives you 2.6 seconds of tension. The picture darkens and pushes in on your face, a line appears ( "I see you…" ), and then comes the hit. It looks great. But a player pointed out that it warns you. You see your eyes going black, you brace yourself, and the hit lands on someone who was expecting it.
The feedback, word for word (translated from Spanish): "Instead of looking at the mirror, make the screen go black, have it say 'I can't see you', and then suddenly the scare comes out and makes a lot of noise."
Left: the mirror, you watch it coming. Right: lights out.
That one conversation also led to two changes in the mirror itself:
The mirror felt finished before I showed it to someone. It wasn't.
Things broke all week, several of them in production. Here's every one.
1. My first realistic faces looked like a clothing catalogue. SD-Turbo's first batch was moody portraits of sad people, not horror. The second batch, after I asked for "horror" harder, was half abstract textures. The fix was two-sided:
2. The scare had a rhythm. You looked into the mirror, and 1.3 s later it always started. Anyone who tried it twice could feel it coming. Now the wait is random and the hit is triggered by your blink.
3. The first phone scares vanished. This one hurt. The first phone version was a static page with no database ( scare-tv-mirror ). I later deployed the real one with a database, but the link I'd been sharing still pointed to the old page, and phones had cached it. The scares happened, and none of them was saved. Production showed 0 phone visits while I was recording videos of the mirror working on my own phone.
4. Production crashed because of numpy. I moved the 0–10 jump-level calculation into the domain layer. It lived in a module that imports numpy, which the web service on Render doesn't install. All tests passed locally, but two deploys in a row failed on boot with ModuleNotFoundError: No module named 'numpy'. Now the score lives in a pure-Python module, and before every deploy the checks run in a clean environment without numpy, the same as Render.
5. The admin user existed but couldn't log in. createsuperuser --noinput without a password doesn't fail: it creates a user with an unusable password, and setting the variable later doesn't fix it, because it never runs again. I replaced it with a small ensure_superuser command that runs on every boot and sets or updates the password from the environment.
6. One person counted as five. On the phone, every "Again 👻" recorded a new arrival, so someone who pressed it five times looked like five people. Now one phone visit is one person, and every scare hangs from the person it happened to.
7. I pushed my demo folder to GitHub. GIFs, a video and AI-generated faces went into the repo with a feature commit. It's now in.gitignore and untracked. Boring, but it's in the list because it happened.
8. The veins looked painted on. Looking at the frames for this post, the black-eyes veins were obviously straight strokes drawn on top of the photo. They broke the illusion more than any glitch. Now each vein is a seeded random walk that bends and branches out of the eye socket, thick at the root and thin at the tip, and it's multiplied into the skin with a soft bruise around it instead of painted over it. They grow with the possession, never flicker between frames, and the TV (OpenCV) and the phone (canvas) draw the same trees. The realistic faces were all regenerated with them.
9. Lights out never struck on a real TV. On the phone it worked every time. Then I tried it the way it was meant to be played: laptop behind the living-room TV, webcam on top, lights off. The screen said "I can't see you…" for more than 30 seconds and nothing happened. It was telling the truth. A black screen in a dark room leaves the webcam nothing to see, and lights-out mode waits for a face before it strikes. On a phone, the screen is a few centimetres from your face and that's enough light. On a TV across the room, it isn't. That's also why none of those TV scares reached the database: they never happened.
The real test: the TV in my living room, lights off. (The black screen looks blue because of the TV's backlight and my phone's camera.)
The fix fits the story: if it can't see you, it looks for you. After 2 seconds without a face, the screen slowly lights up in a cold, flickering glow and the text changes to "WHERE ARE YOU…?". A TV that size makes a decent lamp, and as soon as the face model finds someone, it waits a random moment and strikes. Short losses of the face in the dark no longer restart the wait. Deployed the same evening as app.js?v=8.
And from the first days of the TV version:
Two different kinds of AI are in this project, and they do three different jobs.
1. Open models as the ghosts. Face Mesh and YOLO-pose find your eyes, nose and mouth in every frame, and everything scary is drawn on those points. SD-Turbo imagines faces that don't exist. None of the effects use a generative model on your face. It's geometry on top of open-weight landmarks, which is why it runs live in a phone browser.
2. One model grading another. SD-Turbo generates, YOLO-pose judges, and the bad ones are imagined again. No human picked the faces. A cheap, local, checkable loop between two open models is what made Scare-TV 2 work on a laptop.
PHONE (browser) TV (laptop + webcam) camera ─▶ MediaPipe Face Mesh (468 pts, local) webcam ─▶ YOLO11n-pose (6 MB, local) │ │ ├─▶ possession drawn on <canvas> ├─▶ possession drawn with OpenCV/NumPy ├─▶ blink detector (eyelid landmarks) ├─▶ jump-o-meter (domain/rules/flinch.py) └─▶ jump-o-meter ─┐ └─▶ local SQLite ─┐ (background upload) ▼ ▼ POST /api/web/scares POST /api/tv/nights|arrivals|scares └──────────────▶ Django + DRF on Render ◀────────┘ PostgreSQL · one row per person · /admin SD-Turbo (laptop) ─▶ horror portrait ─▶ YOLO-pose: possessable? ─▶ yes: possess it / no: imagine again Enter fullscreen mode Exit fullscreen mode The jump-o-meter. It stores where your face was just before the hit. For the next 1.5 s it tracks how far the face moves and how much it shrinks (shrinking means you leaned back), both in eye-distances, so it doesn't matter how far you stood from the camera.
Leaving the frame counts as fled and scores a 10. I froze it on October 30, before anyone was scared, so I couldn't tune it to make my favourite effect win.
One row per person. A person is someone who walks up to the TV, or one phone visit. Each scare stores its own jump level (0–10, the same number as on the polaroid) and points to its person. The person keeps a recomputed summary: scare count, highest jump level, fled. False alarms (the F key on the TV, or a checkbox in the admin) don't count.
TVs sync to production without losing anything. The TV writes to its local database first, then mirrors every record to Render from a background thread, so a slow connection never delays a scare. Every record travels with the id it already has locally, so a retry, or make upload the next morning, can never create a duplicate. Several TVs can send at once.
Privacy is in the architecture, not in a policy. There's no endpoint that accepts an image. The API accepts names and numbers, validated: a scare name must match ^[A-Za-z0-9_-]{1,80}$, jumps are capped, and timestamps can't be in the future.
The sound is all synthesized in code, with no samples: screams made from a glottal pulse train pushed through vowel formants, detuned violin stabs, a sub-bass hit with a reversed swell rising into it. It follows the same 2.6 s + 2.4 s timeline as the picture, so the hit lands on the frame where the image jumps.
The code is Django with a hexagonal architecture and CQRS. The domain (face geometry, jump-o-meter, summaries) is pure Python, and every model (YOLO-pose, SD-Turbo, MediaPipe, the Mac's voice) sits behind an adapter that can be swapped. 53 tests run without a TV or a webcam: they check that each possession really changes the picture, that the audio hit lands when the picture jumps, that the jump-o-meter refuses to invent a value without a face, that the API never accepts anything that looks like an image, and that sending the same scare twice stores it once.
The TV runs at home. Everything public runs on Render: the phone mirror, the open-weight model it downloads, the API, the database and the admin. Render is also what kept the feedback loop this short.
The face model doesn't run on a GPU somewhere: it runs on the visitor's phone. Render's job is to deliver it. Every visit downloads MediaPipe Face Mesh (the graph, the packed weights and a WebAssembly runtime, about 10 MB) from the same Render service that hosts the page, with no third-party CDN. Then the phone posts back one number per scare.
This is a real visit from an iPhone, straight from Render's logs: first the model files, then the visit, then the scares, with Render's health checker pinging /api/health in between:
That's why the bandwidth chart is the interesting one: 58 MB served this month, and the peaks line up with the evening people played (each first visit pulls the ~10 MB model).
render.yaml declares the Python web service (Django + DRF on gunicorn, WhiteNoise for the static files) and a free PostgreSQL 18 database, and wires DATABASE_URL from one to the other. A separate static site, scare-tv-mirror, deploys from the same repo and only exists to send old links to the real one.
databases: - name: scare-tv-db plan: free services: - type: web name: scare-tv runtime: python plan: free buildCommand: pip install --upgrade pip poetry && poetry install --no-root --only web && python manage.py collectstatic --noinput startCommand: python manage.py migrate --noinput && python manage.py ensure_superuser && gunicorn core.wsgi:application --bind 0.0.0.0:$PORT --workers 2 --threads 4 healthCheckPath: /api/health envVars: - key: DJANGO_SECRET_KEY generateValue: true - key: DATABASE_URL fromDatabase: { name: scare-tv-db, property: connectionString } - key: DJANGO_SUPERUSER_PASSWORD sync: false # set by hand in the dashboard, never in the repo Enter fullscreen mode Exit fullscreen mode (trimmed: the full file is in the repo)
On October 31 the web service went through 14 deploys. Two of them failed, and that's the best thing Render did for me all day:
The failed build log says exactly why: the new jump-level code imported a module that imports numpy, and the web service doesn't install numpy.
Because the build failed, the old version kept serving. Nobody who opened the mirror between 13:35 and 14:06 saw a broken page. I moved the score to pure Python, added a check that runs the web code without numpy before every deploy, and the next push went live.
Migrations run on boot. migrate and ensure_superuser run before gunicorn starts, so adding the jump level to every scare (and backfilling the old ones with a data migration) shipped in the same push as the code that needed it.
PostgreSQL 18 on Render's free plan: 6.65% of 1 GB used after a day, connections in single digits, and the transaction spikes are people playing. Render's metrics page even shows the top queries (the admin's) and the table sizes, without installing anything.
And this is what's inside, through the Django admin that the same service hosts:
Free web services sleep after 15 minutes without traffic, and Render warns that the next request can wait 50 seconds or more. That's a terrible scare: the person gives up before the mirror loads. A scheduled GitHub Action pings the service every 5 minutes, which keeps one service awake around the clock within the 750 free instance hours a month.
The keep-alive's first ping at 20:28 ( curl/8.5.0 from GitHub's runner). The 13:11 lines are me probing the TV API by hand earlier in the day.
This project points a camera at people's faces. That sentence should make you nervous, and it's exactly why it could only be built this way.
Open models don't just make AI cheaper. They make it yours to misuse creatively, and safe enough to point at the people you love.
Everything below comes from production on October 31: the Django admin and /api/web/summary. It includes my own test runs and my family playing on their phones in the evening, so read it as a small, honest sample, not a study.
Which possession made people jump most (phone, mean jump in eye-distances): the smile (1.59), black eyes (1.18), faceless (0.94), behind you (0.65), melting (0.62), the double (0.58).
Mirror vs lights out. This is the question the trigger column was added for, and the player was right:
In the dark, the mean jump is 44% higher, the median is 71% higher, and almost nobody stayed still. Watching your own eyes go black is creepy, but you're ready for it. A black screen that whispers and then explodes isn't something you can get ready for. It's 35 scares from a handful of people, so the gap could shrink with more players, but the direction is clear enough that lights out is now the button I tell people to press.
Plug your laptop into the TV. A webcam watches and, when a person walks by, the TV launches a full-screen jump scare with video and sound.
Detection is done by YOLO11n-pose, an open-weight vision model running 100% on your computer. It only reacts to people: cats, curtains or a change in the light won't set it off. Because it's a pose model, it also knows where their eyes, nose and shoulders are. Camera frames are never saved and never leave the computer.
The app speaks Spanish and English ( APP_LANG=es / APP_LANG=en ): the pumpkin, the voices, the polaroid and the scares with text follow the chosen language.
The scare people scream at most isn't a monster. It's their own reflection. The TV turns into a mirror (the live webcam, flipped), the picture slowly darkens and pushes in…