I record every Battlefield 6 session with NVIDIA's ShadowPlay, because who doesn't want to relive that one clutch double kill at 2am? The problem is that "recording everything" very quickly turns into a graveyard of 4K DVR files nobody will ever open again. 105 minutes of footage from a single afternoon, most of it me reloading behind a wall.
So I decided to automate the boring part: find the kills, cut them, and stitch them into one highlight reel. No manual scrubbing through 20 clips at 2x speed anymore.
As usual, a Github repository is available at the end if you want to skip the reading and just go try it 😉
Objective
The goal was simple on paper: point a script at my ShadowPlay folder, and get one final 4K video containing only the good bits, same resolution and quality as the source. Bonus points if it also works for whoever wants to reuse it for their own BF6 clips.
I found an existing project on Github that did something similar for older Battlefield titles, detecting the killfeed (the little box that stacks up in a fixed corner of the screen when you get a kill). Great starting point conceptually. Except for one small detail...
The Sad Part Of The Story
BF6 does have a killfeed: top-right corner, classic "Killer 🔫 Victim" list, same as every other Battlefield. But that feed shows everyone's kills, the whole match, not just mine. To use it for a personal highlight reel I'd have to OCR every entry and string-match it against my own username, which is exactly the kind of fragile, brittle thing I was trying to avoid.
Turns out there's something better: BF6 also shows a separate, personal kill-confirmation overlay every time you get a kill: a skull icon, the damage number, the enemy's name and a little boxed "KILL" text (sometimes with extra badges tacked on, like "SQUAD SAVIOR" or "PROTECTOR"), sitting roughly center-screen, just below the crosshair. It only ever fires for your own kills, so it filters itself for free, no OCR, no username matching needed.
So I pointed a fixed-region brightness/contour detector (crop that region, threshold the bright pixels, count the blobs) at it, same idea as the old repo's killfeed approach. It "worked", in the sense that it produced detections. Then I actually watched the timestamp it flagged:
A wet puddle reflecting the sky. That's it. That's the "kill".
Turns out ordinary bright scenery (puddles, pale vehicles, a glimpse of sky through a window) trips a brightness threshold just as easily as a skull icon does — and so do the other legitimate HUD badges ("PROTECTOR", "SQUAD SAVIOR", "OBJECTIVE ARMED") that can pop up in that exact same spot without you having actually gotten a kill. Brightness alone just isn't a specific enough signal, cropped region or not.
The Fix: Template Matching
Instead of asking "is this pixel bright", I started asking "does this region of the frame look like the literal 'KILL' box graphic". That's a job for cv2.matchTemplate: crop a clean reference image of the box once, slide it across a search region of each sampled frame, and keep the best correlation score.
class KillfeedDetector:
def __init__(self, detection_cfg, template_path, src_width):
template = cv2.imread(template_path, cv2.IMREAD_GRAYSCALE)
scale_factor = detection_cfg.get("detect_scale_width", src_width) / src_width
self.template = cv2.resize(template, None, fx=scale_factor, fy=scale_factor)
self.match_threshold = detection_cfg.get("match_threshold", 0.72)
def detect(self, frame, frame_num, time_sec):
roi, ox, oy = self.crop_roi(frame)
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
result = cv2.matchTemplate(gray, self.template, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(result)
if max_val < self.match_threshold:
return []
return [Detection(frame=frame_num, time_sec=time_sec,
x=ox + max_loc[0], y=oy + max_loc[1], confidence=max_val)]
The difference was night and day. I ran it against a 32 seconds clip with two known real kills in it and printed the match score for every sampled frame:
t= 6.20s score=0.561
t= 16.60s score=0.624
t= 17.80s score=0.817 <=== real kill starts
t= 18.00s score=0.873
t= 19.00s score=0.882
...
t= 20.80s score=0.673
t= 26.20s score=0.839 <=== second kill
t= 27.00s score=0.878
Everything that isn't a kill hovers under ~0.65. Every real kill jumps to 0.79+. A threshold of 0.72 cleanly separates signal from noise, and it stopped caring about puddles entirely. Much better than chasing brightness values.
Turning Detections Into A Highlight Reel
Once I had reliable per-frame detections, the rest is basically an events pipeline:
- Group detections that are close in time (within ~0.8s) into a single kill event. The marker stays visible for a couple of seconds, so one kill produces a short burst of matches, not just one.
- Cluster kill events that happen close together (within 6s by default) into one highlight window. Two or three kills within a few seconds of each other become one continuous clip instead of a choppy back-to-back cut and if a window contains 2+ events, it gets tagged
MULTI_KILLinstead ofKILL. - Pad each window with some lead-in and lead-out (4s before, 2.5s after by default), so you actually see the approach and not just the confirmation text.
- Cut each window out of the source clip with FFmpeg, re-encoding (NVENC locally,
libx265in the Docker image) so the cut lands exactly where it should and the audio survives. - Concatenate every cut segment, in chronological order, into one final video.
You get to choose what counts as a "highlight" through a single config value:
"clipping": {
"allowed_tags": ["MULTI_KILL"]
}
["MULTI_KILL"] only keeps clustered multi-kills. ["KILL", "MULTI_KILL"] keeps every single kill too. Simple enough to tweak per session depending on how trigger-happy I was.
The Split Workflow
I didn't want an all-or-nothing tool. Sometimes you want to eyeball what got detected before committing to a final render, so the CLI has three modes:
python build_highlights.py clips # detect + cut every highlight into output/segments/
python build_highlights.py assemble # concat whatever is left in output/segments/
python build_highlights.py full # clips + assemble in one go
clips also writes an index.csv next to the segments, so you know exactly which source clip and timestamp each cut came from. Delete whatever you don't want, then run assemble. It just concatenates whatever .mp4 files are still sitting there. Deliberately dumb, and exactly what I wanted.
Dockerized It (Because Why Not)
I run this locally with a Shotcut-bundled FFmpeg and NVENC hardware encoding, which is fast, but obviously not something I can expect a random Github visitor to have lying around. So the whole thing is also packaged as a Docker image, defaulting to software encoding (libx265) so it runs anywhere, no GPU required:
docker build -t bf6-highlights .
docker run --rm \
-v "/path/to/your/recordings:/input" \
-v "/path/to/output:/output" \
bf6-highlights full
If you do have an NVIDIA GPU and nvidia-container-toolkit configured, you can swap the codec back to hevc_nvenc in the mounted config for a nice speed boost.
Does It Actually Work?
First real batch: 21 clips, roughly 105 minutes of 4K footage. Result: 48 detected highlight windows, assembled into an 8-minute final video, same 3840x2160@60fps as the source, audio intact.
I spot-checked a handful of the cuts by pulling stills at the timestamps. One of them landed on this, completely unprompted by me:
DOUBLE KILL 💀💀
The game's own UI confirmed what my clustering logic had already guessed. That felt pretty good. It also turned out to matter a lot more than I realized at the time — more on that below.
Round Two: The Kill That Said "HEADSHOT" Instead
Fed the tool a fresh batch of clips a while later and noticed a kill I distinctly remembered pulling off never made the reel. Went and checked the footage manually. Turns out BF6 doesn't always show "KILL" — for headshots it swaps the text for "HEADSHOT" entirely. Not appended. Swapped. My template, cropped tightly around the word "KILL", had zero reason to ever fire on a completely different word.
Fix seemed obvious enough: crop a "HEADSHOT" template too, add it to the list. Ran it. It produced matches, so job done, right? I checked what it was actually matching on:
- "DEFENDING"
- "NEUTRALIZING"
- "SUPPLY"
- "DEFENSE DEPLOYED"
Every single one of those is the exact same bordered, single-word HUD notification style as basically everything else in this game. My original "KILL" template survived that trap by pure luck of being a short, distinctive shape. "HEADSHOT", same font, same box, just a longer word, blurred together with a pile of unrelated objective-status spam once everything gets downscaled for matching. Brightness thresholding all over again, with extra steps.
The actual fix: BF6 gives headshot kills a differently-colored skull icon: orange, with a little lightning bolt, instead of the plain gray one used for regular kills. That icon doesn't show up anywhere else in the UI. Matched on that instead of any word, and the false positives disappeared completely: real headshots scored 0.90-0.97, every single piece of HUD noise topped out around 0.80.
class KillfeedDetector:
def __init__(self, detection_cfg, template_specs, src_width):
# template_specs: [{"path": ..., "match_threshold": ..., "label": "KILL"}, ...]
self.templates = [load_and_scale(spec, src_width) for spec in template_specs]
def detect(self, frame, frame_num, time_sec):
roi, ox, oy = self.crop_roi(frame)
gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
best = None
for tmpl in self.templates:
result = cv2.matchTemplate(gray, tmpl.image, cv2.TM_CCOEFF_NORMED)
_, score, _, loc = cv2.minMaxLoc(result)
if score >= tmpl.threshold and (best is None or score > best.score):
best = Match(score, loc, tmpl.label)
return [Detection(frame_num, time_sec, best)] if best else []
One detector, an arbitrary list of labeled templates, whichever one wins tells you what kind of kill it was, instead of one detector hardcoded to a single graphic.
What About Triple Kills, Quad Kills, "Marodeur"...?
Turns out BF6 also has a whole family of multi-kill banners: DOUBLE KILL, TRIPLE KILL, QUAD KILL, and apparently named tiers beyond that (a YouTube clip I stumbled on showed "Marodeur" for a 6-kill streak on a German client, every other label was translated except, oddly, "KILLS x 6", which stayed in English). I looked at chasing that for a moment. Then remembered I already have a mechanism that catches multi-kills perfectly well without reading a single word of banner text: if 2+ kill confirmations land within a few seconds of each other, that's a MULTI_KILL, whatever text the game happened to draw over it, in whatever language. Building and calibrating a template for every count-word in every locale the game ships is a maintenance treadmill for zero real benefit, the DOUBLE KILL spot-check above already proved the clustering does the job. So: no banner templates.
New Trick: Filter By Kill Type
Now that KILL and HEADSHOT are tracked separately all the way through, picking what goes in the reel got one config line richer:
"clipping": {
"allowed_tags": ["KILL", "MULTI_KILL"],
"allowed_kill_types": ["HEADSHOT"]
}
Want a highlight reel of nothing but headshots? That's the whole feature.
Wrap Up
Github repo is here, MIT licensed, Docker-ready: github.com/mydnic/battlefield6-highlights-auto-clip
If you record BF6 (or honestly any game with a similarly-shaped kill confirmation UI), feel free to grab it, recrop the template image for your game, and let me know how it goes. And if it saves you from ever manually scrubbing through DVR footage again, smash that star button on the repo a thousand times ❤️