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.
TL;DR
Scare-TV possesses your own reflection: black eyes, a mouth that tears open, a grin that isn't yours, a pale double that lags a second behind you. It runs on a TV with a webcam (YOLO11n-pose) and on any phone (** MediaPipe Face Mesh**, in the browser). No frame ever leaves the device.
- A jump-o-meter measures how far your face moves in the 1.5 s after the hit. That single number is the only thing the server gets. Production stores one row per person, from TVs and phones, with their highest jump level (0–10). A player's feedback shipped the same day: lights-out mode. Both modes are stored separately, and the data says the player was right: in the dark, people jumped 44% more on average (35 real scares on October 31). Scare-TV 2: a second open model, SD-Turbo, imagines horror faces on my laptop. YOLO-pose grades every one, and only faces it can possess get to scare anyone. 8 of 10 made it. Plus 20 classic scares for anyone who doesn't want to turn the camera on. Nine things broke (one silently threw away every phone scare, another only showed up on a real TV in a dark living room). Each one is below, with the fix. All of the public side runs on Render from one Blueprint: Django + DRF, PostgreSQL, the admin, and a keep-alive so the first scare of the night never waits for a cold start. Try it now on your phone: scare-tv.onrender.com. Dim the lights, turn the sound up, and pick Lights out 🌑 if you're brave.
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:
| What happens to you | |
|---|---|
| Black eyes | Your eyes fill with black and dark veins branch out across your skin. "I see you." |
| Faceless | Your features smooth into blank skin, then a mouth full of teeth tears open. |
| Behind you | A pale girl with long black hair peeks out from behind your shoulder. "Don't turn around." |
| Melting | Your face drips downward and your eyes go hollow. |
| Smile | A grin from ear to ear that isn't yours. "Smile for me." |
| The double | Your reflection lags a second behind, pale and black-eyed, and creeps closer. |
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."
After: lights-out mode.
web or web_dark), so the question
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.
app.js?v=6), so phones drop the cached copy.
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
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
(trimmed: the full file is in the repo)
healthCheckPath means Render only switches traffic to a new deploy once /api/health answers (that's the Render/1.0 line in the logs every few seconds).sync: false variable I typed in the dashboard.
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:
| Time (CET) | What shipped | Result |
|---|---|---|
| 13:13 | Night log in production: TVs upload to Render, Django admin | ✅ live |
| 13:26 | Admin login fixed ( ensure_superuser ) |
✅ live |
| 13:35 | One row per person, with their jump level | ❌ build failed |
| 14:01 | Old static mirror redirects to the real site | ❌ build failed |
| 14:06 | Jump level in pure Python | ✅ live |
| 14:27 | Lights-out mode, a player's idea | ✅ live |
| 19:24 | Organic veins + whispers in the dark | ✅ live |
| 19:32 | Keep the service awake | ✅ live |
| 20:53 | Lights out on a real TV: the screen searches for you | ✅ live |
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.
| People | Scares | Fled the frame | |
|---|---|---|---|
| 📺 TV (morning test sessions) | 4 | 23 | 0 |
| 📱 Phone mirror | 12 | 35 | 0 |
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:
| Mode | Scares | Mean jump | Median jump | Big jumps (level ≥ 5) | No reaction (level 0) |
|---|---|---|---|---|---|
Mirror ( web ) |
16 | 0.71 | 0.45 | 3 | 7 |
Lights out (web_dark) |
19 | 1.02 | 0.77 | 5 | 2 |
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.
--save-reactions, and then you should ask people first.
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…
make install, then make run for the TV, make run2 for the realistic faces, make web for the phone mirror, and make upload to send a night to production. make on its own lists everything.
The moment it landed for me wasn't a scream. It was a message: a player telling me exactly why my scare didn't work on them. A few hours later, the screen they described existed, and it was recording, in a column called trigger, whether they had been right. By the evening, with my family passing phones around, that column had its answer: they were.
This is my last post of Hacktoberfest 2026, and I want to end it with something that isn't code.
I've done Hacktoberfest before. For me it used to mean opening a few pull requests and calling it done. This year it was a whole month: a weekend challenge and four weekly ones, each with a theme that made me build something I'd never have thought of on my own. A beautician's receptionist, a walk you're too tired to take, a mirror that hunts you in the dark. I went from "three PRs" to some of the most intense and fun weeks I've had as a developer.
To the DEV and MLH teams: what you put together this month was genuinely spectacular. A new theme every Monday, partners from all over the ecosystem, and a hub that made it easy to follow along. Thank you for treating a month of community challenges like a real event, because it felt like one.
To the sponsors: thank you for the credits and promo codes that let us build real things instead of mockups. This project runs on Render because I could try it without thinking about a bill, and it speaks with ElevenLabs because a code let me experiment. I learned something new from every tool I touched this month, and I'm keeping all of them in my toolbox from now on. Not as a thank-you gesture: because they're good, and now I know how to use them.
I'm going to miss the Monday-morning theme reveal. See you at Hacktoberfest 2027. I'm already counting the days. 🎃
render.yaml Blueprint deploys the whole public side: a Python web service (Django + DRF on gunicorn, WhiteNoise), a Render PostgreSQL database that keeps one row per person from every TV and phone, the Django admin to explore it, migrations and an idempotent ensure_superuser on every boot, and a keep-alive so it never sleeps. Every push to main redeploys, which is how a player's message became a live feature in an afternoon, and how two broken builds never reached a single visitor.