I’ve won many marketing video contest promotions over the past decade using my proven techniques and tactics. This particular haul, however, was the first where I can give at least partial credit to the application of code and AI tools.
Here’s how I used ChatGPT and a little bit of JavaScript to figure out I could win this video contest... and then proceed to win it.
My second kid was born in June. I’m loving it, of course, but there’s not much I can do with bleary eyes and a six-week old strapped to my chest.
However…
Walking around the house tending to my Honeydew list with a spray can of WD-40 falls squarely into the achievable bucket.
As such, the WD-40 Repair Challenge video contest caught my attention.
I don’t get very excited about online contests. I get excited when online contest rules are structured in a way that makes them particularly winnable.
First, let’s walk through the different qualifications and clauses in this contest’s ruleset that made it a great candidate for participation.
Three favorable rules
1. A gotcha in the judging criteria
I only enter contests where entrant submissions are judged. Random selection sweepstakes or awards for most votes (popularity contests) are never considered.
This weighted breakdown contained a line that ultimately ruled out over 80% of the submissions.
By awarding of the judging points to the submission simply being video content, it’s clear that the promoter was prioritizing video entries.
However, the contest also accepted photo submissions in addition to video submissions. Since photo submissions immediately receive a under this weighted clause, I decided to rule out all entrant photo submissions as non-competitive since their maximum submission score could only be .
For this contest, I also liked that the overall quality of the content didn’t really matter all that much. Normally, a heavier weight associated with this kind of clause would work in my favor as I prioritize high quality work, but remember… I have a baby strapped to my chest. You just ain’t gonna get my best output at the moment.
2. A wide array of winnable prizes
Obviously, we’re aiming for first place here, but there are 16 cash prizes and 13 physical prizes up for grabs — a total of 29 available prizes. That’s unheard of.
Still, we need to be sure that our chances at winning the prize(s) outweigh the effort required to submit a competitive entry. We’re not trying to take on a side-hustle moonlighting contract here. We want to put in as few hours as possible for the best possible outcome.
3. The possibility to win more than one prize
If you have the capacity to create a bunch of entries (), this should clearly be great news for you.
Assessing the competition
All told, there were 538 entries competing for prizes in the contest. doesn’t sound like great odds, does it?
Let’s dig deeper to see why winning isn’t as improbable as it sounds on the surface. To do this, we’ll review the existing submissions.
The contest website was built using Laravel Livewire. Livewire doesn’t return raw JSON data about the contest submissions from its API — instead, it serves HTML over the wire. That meant that I couldn’t just hit a /submissions
endpoint to get information about each submission — I’d have to scrape it.
I set up a Playwright script to page through the entry gallery and collect the data I’d need to assess the existing competitive entries.
const { chromium } = require("playwright");
const startScraping = async () => {
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage();
await page.goto("https://repair.wd40.com/gallery");
// click button with "Reject All" as the button label
await page.click('button:text("Reject All")');
await getAllEntries(page);
await browser.close();
}
startScraping();
Pretty basic to begin. We’re navigating to the submission gallery, declining the cookies banner, and then initiating the submission aggregation process.
Sorting through submission types
As part of understanding my odds, I needed to determine the type of submission for each entry. Remember, I can rule out any photo entries as competitors due to the significant point reduction they’ll face.
The only way to figure out the type of submission for each entry was to detect which SVG icon was used to represent the submission.
Upon further review, it turns out that there was actually three different submission types: video
, photo
, and step
.
Step submissions allowed for providing additional written detail in your entry. Since clearly identifying the steps taken to repair your project accounted for points, I assumed that a step
entry type was the most likely to win a prize. At this point, I decided all of my submissions would now be step
entries.
Here’s how the getAllEntries
scraping pagination function and submission type attribution was implemented:
// Use the source code for the submission icons to identify submission types
const IMG_ICON = `<path d="M14 9l-2.519 4-2.481-1.96-5 6.96h16l-6-9zm8-5v16h-20v-16h20zm2-2h-24v20h24v-20zm-20 6c0-1.104.896-2 2-2s2 .896 2 2c0 1.105-.896 2-2 2s-2-.895-2-2z"></path>`.trim();
const VIDEO_ICON = `<path d="M16 18c0 1.104-.896 2-2 2h-12c-1.105 0-2-.896-2-2v-12c0-1.104.895-2 2-2h12c1.104 0 2 .896 2 2v12zm8-14l-6 6.223v3.554l6 6.223v-16z"></path>`.trim();
const STEP_ICON = `<path d="m22 17.75c0-.414-.336-.75-.75-.75h-13.5c-.414 0-.75.336-.75.75s.336.75.75.75h13.5c.414 0 .75-.336.75-.75zm-18.25-2.75c.966 0 1.75.784 1.75 1.75s-.784 1.75-1.75 1.75-1.75-.784-1.75-1.75.784-1.75 1.75-1.75zm18.25-1.25c0-.414-.336-.75-.75-.75h-13.5c-.414 0-.75.336-.75.75s.336.75.75.75h13.5c.414 0 .75-.336.75-.75zm-18.25-3.75c.966 0 1.75.784 1.75 1.75s-.784 1.75-1.75 1.75-1.75-.784-1.75-1.75.784-1.75 1.75-1.75zm18.25-.25c0-.414-.336-.75-.75-.75h-13.5c-.414 0-.75.336-.75.75s.336.75.75.75h13.5c.414 0 .75-.336.75-.75zm-18.25-4.75c.966 0 1.75.784 1.75 1.75s-.784 1.75-1.75 1.75-1.75-.784-1.75-1.75.784-1.75 1.75-1.75zm18.25.75c0-.414-.336-.75-.75-.75h-13.5c-.414 0-.75.336-.75.75s.336.75.75.75h13.5c.414 0 .75-.336.75-.75z" fill-rule="nonzero"></path>`
let totalEntries = 0; // Initialize a counter for total entries
let imageEntries = 0; // Initialize a counter for image entries
let stepEntries = 0; // Initialize a counter for step entries
let videoEntries = 0; // Initialize a counter for video entries
const getAllEntries = async (page) => {
const entries = await page.$$("ul.grid li.contents");
totalEntries += entries.length; // Increment total entries by the number of entries found on this page
for (const entry of entries) {
const entryHtml = await entry.innerHTML();
const a = await entry.$("a");
const href = await a.getAttribute("href");
if (entryHtml.includes(IMG_ICON)) {
// This is a non-competitive photo entry
imageEntries++; // Increment image entries counter without logging
} else {
if (entryHtml.includes(VIDEO_ICON)) {
console.log('Competitive video entry found');
videoEntries++; // Increment video entries counter
} else if (entryHtml.includes(STEP_ICON)) {
console.log('Competitive step entry found');
stepEntries++; // Increment step entries counter
} else {
console.log('Unknown entry type');
}
console.log(href); // Log href to non-image entries for inspection
}
}
const nextButton = await page.$('a[rel="next"]');
if (nextButton) {
await nextButton.click();
await page.waitForResponse(response => response.status() === 200);
// wait a few seconds to allow for loading and prevent rate limiting
await page.waitForTimeout(3200);
await getAllEntries(page); // recursive call
} else {
console.log('No next button found, reached the end');
// All done, we can now assess the submissions and make our conclusions here
console.log(`Total entries processed: ${totalEntries}`);
console.log(`Image entries: ${imageEntries}`); // Log the total number of image entries
console.log(`Video entries: ${videoEntries}`); // Log the total number of video entries
console.log(`Step entries: ${stepEntries}`); // Log the total number of step entries
}
}
In this function, we’re recursively performing a series of tasks:
- Get the number of descendants in the
ul.grid li.contents
element (each descendant is a contest entry) - For each descendant;
- Get the
href
so we can log it out to the console if we care to review the entry - Detect which SVG icon is used to represent the entry
- If it’s an image entry, ignore it, since we’re fairly confident we can beat it
- Increment the corresponding entry type counter
- Look for the Next page button
a[rel="next"]
- If it exists, click it, wait for the next page of entries to load, and repeat the process from Step 1.
- If it doesn’t exist, we’ve reached the end of all submissions and can log out our results
I ran the script with node index.js
. The following results were printed in the console:
No next button found, reached the end
Total entries processed: 538
Image entries: 439
Video entries: 17
Step entries: 67
Of the contest entries, of them were understood to be unqualified image entries.
That’s 81% of all entries that we can immediately disregard. The Pareto principle. Of course!
Let’s remove those 439 entries from consideration now:
Remember, this judged contest has 29 prizes.
Quality still matters
We’ve already weeded out the vast majority of entries as being non-competitive, but there’s still more we can disregard. This is a judged competition. Low quality entries, poor storytelling, hokey acting, and bad editing are just a few of the pillars where coming up short can lead you to the losers bucket.
With this being the case, I’m going to role play a bit and pretend I’m a judge. If your entry simply doesn’t meet what I’d consider to be a quality piece of content, I’m going to loosely call it a loser.
I visited a chunk of these entries and watched them. If the video/step submission simply wasn’t very good, I’d copy the submission slug and store it as a non-competitor in an array. Then, while paging through the gallery, if a losing slug was detected, I’d delineate it as a nonCompetitor
.
// Check if the href contains any known non-competitor
const KNOWN_NON_COMPETITORS = ['imagine', 'some', 'not', 'so', 'great', 'entry', 'slugs', 'in', 'this', 'list', 'that', 'have', 'been', 'redacted', 'to', 'be', 'kind']
let nonCompetitorEntries = 0; // Initialize a counter for non-competitor entries
const isNonCompetitor = KNOWN_NON_COMPETITORS.some(nonCompetitor => href.includes(nonCompetitor));
if (isNonCompetitor) {
nonCompetitorEntries++; // Increment non-competitor entries counter
continue; // Skip logging and counting this entry
}
<video>
tag player.
They could’ve easily improved it by using a service like Mux.I didn’t have a chance to review all the submissions, but it turns out that at least 23 submissions could be considered subpar entries. That brings the entry odds ever closer to working in our favor:
With a single entry, I had a 1 in 77 shot at winning $5,000.
This was starting to look pretty decent, but since multiple submissions were acceptable, I had to do what I could to maximize the number of submissions I could feasibly complete while still dadding it up.
I updated the logging to include the number of competitive entries. This would be the number that I’d compare all probabilities against:
console.log(`Total entries processed: ${totalEntries}`);
console.log(`Image entries: ${imageEntries}`); // Log the total number of image entries
console.log(`Non-competitive entries: ${nonCompetitorEntries}`); // Log the total number of non-competitor entries
console.log(`Video entries: ${videoEntries}`); // Log the total number of video entries
console.log(`Step entries: ${stepEntries}`); // Log the total number of step entries
const competitiveEntries = totalEntries - nonCompetitorEntries - imageEntries;
console.log(`Competitive entries: ${competitiveEntries}`); // Log the total number of competitive entries
Calculating odds across multiple prizes
I had to better understand my chances of winning multiple prizes if I were to submit multiple entries. To do that, I’d have to assess the prizes in greater detail:
const NUMBER_OF_PERSONAL_ENTRIES = 8;
const prizeDistribution = [
{ award: "grand prize", quantity: 1, amount: "$5,000" },
{ award: "second place prize", quantity: 1, amount: "$3,000" },
{ award: "third place prize", quantity: 1, amount: "$1,500" },
{ award: "fourth place prize", quantity: 7, amount: "$500 Lowe's gift card" },
{ award: "fifth place prize", quantity: 6, amount: "$250 Lowe's gift card" },
{ award: "sixth place prize", quantity: 13, amount: "WD-40 Branded Yeti® Rambler® One Gallon Jug" }
];
Let’s update the logging to output more info about the prizes:
console.log(`Number of prizes: ${prizes.reduce((acc, curr) => acc + curr.quantity, 0)}`); // Log the total number of prizes
Now we can get to figuring out the probabilities and odds. For this, we’ll use a function to calculate the binomial coefficient.
I’ll be honest: I am not a mathematician. This was starting to get outside my area of knowledge, but what I was trying to accomplish was not exactly an uncommon task. I felt pretty confident that GPT4o would be able to provide the code to calculate my odds given an appropriate prompt:
I am trying to understand my odds of winning a contest. The contest allows me to submit multiple entries. The judges are selecting a total of 29 winners. there is one first place winner, one second place winner, one third place winner, seven fourth place winners, six fifth place winners, and 13 sixth place winners. I'd like to learn my chances to win based on if i submit n number of entries. i also know the total number of existing submissions, so I can pass that number as an argument to the function provide the javascript code to help me calculate my chances of winning. I want to provide the number of my entries, the number of competing entries, and learn my odds of winning.
Here’s the code that GPT4o came up with:
function binomialCoefficient(n, k) {
if (k > n) return 0;
if (k === 0 || k === n) return 1;
let coefficient = 1;
for (let i = 1; i <= k; i++) {
coefficient *= (n - (k - i));
coefficient /= i;
}
return coefficient;
}
// Function to calculate the probability of winning exactly k prizes
function probabilityOfWinningKPrizes(totalEntries, yourEntries, totalPrizes, k) {
if (k > yourEntries || k > totalPrizes) return 0;
let otherEntries = totalEntries - yourEntries;
let waysForYouToWinKPrizes = binomialCoefficient(yourEntries, k);
let waysForOthersToWinRemainingPrizes = binomialCoefficient(otherEntries, totalPrizes - k);
let totalWaysToAwardPrizes = binomialCoefficient(totalEntries, totalPrizes);
let probability = (waysForYouToWinKPrizes * waysForOthersToWinRemainingPrizes) / totalWaysToAwardPrizes;
return probability;
}
// Function to convert probability to human-readable odds
function probabilityToOdds(probability) {
if (probability === 0) return "0 (impossible)";
let odds = 1 / probability;
return `1 in ${Math.round(odds).toLocaleString()}`;
}
// Function to calculate detailed probabilities for each prize category
function calculateDetailedProbabilities(totalEntries, yourEntries, prizeDistribution) {
let detailedProbabilities = {};
prizeDistribution.forEach(prize => {
let probabilities = [];
let humanReadableOdds = [];
let totalProbability = 0;
for (let k = 0; k <= prize.quantity; k++) {
let probability = probabilityOfWinningKPrizes(totalEntries, yourEntries, prize.quantity, k);
probabilities.push(probability);
totalProbability += probability;
humanReadableOdds.push(probabilityToOdds(probability));
}
console.log(`Total probability for ${prize.award} should be close to 1: ${totalProbability}`);
detailedProbabilities[prize.award] = {
probabilities,
humanReadableOdds
};
});
return detailedProbabilities;
}
// Function to calculate the cumulative probability of winning at least one prize
function calculateCumulativeProbability(totalEntries, yourEntries, prizeDistribution) {
let probabilityOfNotWinningAnything = 1;
prizeDistribution.forEach(prize => {
let probabilityOfNotWinningThisPrize = probabilityOfWinningKPrizes(totalEntries, yourEntries, prize.quantity, 0);
probabilityOfNotWinningAnything *= probabilityOfNotWinningThisPrize;
});
return 1 - probabilityOfNotWinningAnything;
}
// Function to calculate combined probabilities for winning across all prize categories
function calculateCombinedProbabilities(totalEntries, yourEntries, totalPrizes) {
let combinedProbabilities = [];
let combinedHumanReadableOdds = [];
let totalProbability = 0;
for (let k = 0; k <= totalPrizes; k++) {
let probability = probabilityOfWinningKPrizes(totalEntries, yourEntries, totalPrizes, k);
combinedProbabilities.push(probability);
totalProbability += probability;
combinedHumanReadableOdds.push(probabilityToOdds(probability));
}
console.log(`Total combined probability for all prizes should be close to 1: ${totalProbability}`);
return { combinedProbabilities, combinedHumanReadableOdds };
}
Lovely.
Now that our utils are in place, let’s use them to print out the various probabilities and odds if we were to enter this contest with a various number of submissions:
// Example usage
const totalEntries = 76;
const yourEntries = 8; // Change this value to test different numbers of your entries
const prizeDistribution = [
{ award: 'First place', quantity: 1 },
{ award: 'Second place', quantity: 1 },
{ award: 'Third place', quantity: 1 },
{ award: 'Fourth place', quantity: 7 },
{ award: 'Fifth place', quantity: 6 },
{ award: 'Sixth place', quantity: 13 }
];
const totalPrizes = prizeDistribution.reduce((acc, prize) => acc + prize.quantity, 0);
const totalEntriesIncludingYours = totalEntries + yourEntries;
let detailedProbabilities = calculateDetailedProbabilities(totalEntriesIncludingYours, yourEntries, prizeDistribution);
// Display detailed probabilities with human-readable odds
Object.keys(detailedProbabilities).forEach(prize => {
let prizeInfo = detailedProbabilities[prize];
console.log(`Your probabilities of winning 0 to ${prizeDistribution.find(p => p.award === prize).quantity} ${prize} prizes are:`, prizeInfo.probabilities);
prizeInfo.humanReadableOdds.forEach((odds, index) => {
console.log(`The odds of winning exactly ${index} ${prize} prizes are: ${odds}`);
});
});
// Calculate cumulative probability of winning at least one prize
let cumulativeProbability = calculateCumulativeProbability(totalEntriesIncludingYours, yourEntries, prizeDistribution);
console.log("Your cumulative probability of winning at least one prize is:", cumulativeProbability);
// Calculate combined probabilities for all prizes
let combinedResults = calculateCombinedProbabilities(totalEntriesIncludingYours, yourEntries, totalPrizes);
// Display combined probabilities with human-readable odds
console.log(`Your probabilities of winning 0 to ${totalPrizes} prizes across all categories are:`, combinedResults.combinedProbabilities);
combinedResults.combinedHumanReadableOdds.forEach((odds, index) => {
console.log(`The odds of winning exactly ${index} prizes across all categories are: ${odds}`);
});
Drumroll…
The resulting math was saying that, with 8 entries, I had a better chance of randomly winning 1, 2, 3, 4, or even 5 prizes than I did at winning zero prizes, and there was a 95% chance that I’d win at least one prize.
Total probability for First place should be close to 1: 1
Total probability for Second place should be close to 1: 1
Total probability for Third place should be close to 1: 1
Total probability for Fourth place should be close to 1: 1
Total probability for Fifth place should be close to 1: 1
Total probability for Sixth place should be close to 1: 1
Your probabilities of winning 0 to 1 First place prizes are: [ 0.9047619047619048, 0.09523809523809523 ]
The odds of winning exactly 0 First place prizes are: 1 in 1
The odds of winning exactly 1 First place prizes are: 1 in 11
Your probabilities of winning 0 to 1 Second place prizes are: [ 0.9047619047619048, 0.09523809523809523 ]
The odds of winning exactly 0 Second place prizes are: 1 in 1
The odds of winning exactly 1 Second place prizes are: 1 in 11
Your probabilities of winning 0 to 1 Third place prizes are: [ 0.9047619047619048, 0.09523809523809523 ]
The odds of winning exactly 0 Third place prizes are: 1 in 1
The odds of winning exactly 1 Third place prizes are: 1 in 11
Fourth place prize probabilities
Fifth place prize probabilities
Sixth place prize probabilities
Cumulative probabilities
I’m definitely entering this contest.
I ran out to a nearby hardware shop and bought a single can of WD-40 for a total of $7.87.
High quality, low effort assets
With the deadline quickly approaching (and many other obvious priorities), I didn’t have a ton of time to waste. Luckily, fixing squeaky hinges, stuck sliding doors, and finicky latches wasn’t really a big investment. I captured all the footage on my iPhone 13, hitting record, and simply performing these small fixes on my 1930s house.
I’d worry about the narration, music, and edit later; for now, I just wanted to adhere to recording content that prioritized the weighted breakdown.
I was ultimately able to dedicate enough time to record 8 tasks over the span of a single morning. I spent about 15 to 20 minutes on each of:
- Cleaning my mailbox
- Unsticking my door latch
- Lubricating a sliding door
- Quieting a squeaky door hinge
- Removing an old service sticker from my furnace
- Lubricating an HVAC register lever
- Removing permanent marker from an art board
- Protecting a fairy’s tree stump house
Now that I had my raw footage, it was time to edit. I dropped the raw recordings into Adobe Premiere and chopped up the individual shots that best showed the repair happening in action.
The formula
Each video followed the exact same formula:
- Introduce the problem
- Reassure that the problem could be fixed with WD-40 and basic household tools
- Break down the steps to resolve the problem in detail
- Show the repaired item, now functioning better with the help of WD-40
In previous contests, I always had to write and record the narration for my entries on my own microphone, stumbling over lines, re-recording to account for dog barks or baby cries in pursuit of the perfect take.
Elevenlabs
Elevenlabs made the narration process unreasonably easy. I wrote a simple voiceover script adhering to the formula for each task, selected a voice from the Elevenlabs library, and pasted the script into the Elevenlabs speech generation tool.
Most of the time, the results were perfect on the first try. Only a few scripts had to be regenerated due to mispronunciations or strange emphasis.
I’d download the narration MP3 and drop it into my Premiere timeline, using it to drive the cadence of the final edit.
Suno
The last missing piece was a little background music. Historically, I’d search creative commons archives for the perfect little soundtrack, but this time, AI tools made the job ridiculously simple.
Suno allowed me to type in the kind of vibe I was going after. After a few generations, I had the perfect HGTV-style track, an excellent companion to the DIY videos I was putting together.
soft drums, electric guitar, folk, acoustic, banjo, instrumental
All told, the edits took up about ~8 hours of my time. I was ready to submit!
The email
On July 24th, 2024, at 4:47 PM, three hours and forty-seven minutes past the defined Winner Notification deadline clause*, I received this email:
7 out of 8 entries had won a prize.
Lingering questions you may have
What happened with the entry that didn’t win?
I took a bit of a turn in the format formula with entry #8 and decided to put a moonshot out there that was more cinematic, storyful, and fun to aim for the grand prize. Ultimately, it was my favorite of all the entries, and the one I’m most proud of, but it didn’t win. It’s a good lesson about how weighted breakdowns do not care about your creativity (unless otherwise specified).
Are you bummed you didn’t win 1st, 2nd, or even 3rd place?
Kinda, but I think the judges nailed it and I got the max placement that I deserved. None of the projects I submitted were particularly interesting or life-changing.
Did you spend more time working on this blog post than on the contest?
Yes.
…Why?
Because writing is important, it captures a fun hobby of mine, and I want to remember how ridiculous of a time in my life this was.
I like your style. Can you help me to get better at writing?
Maybe! Let’s talk.
What are you going to spend the winnings on?
I dunno. Current frontrunner is a commercial ice cream machine. Let me know if you have any good ideas.
Where do you find these exceedingly winnable contests?
Sorry, that’s a trade secret — but it’s not any of the standard various contest aggregators out there.
*In all cases, the computer of Administrator (as defined in the contest rules) is the official timekeeping device of this Contest. Must have had a little time sync issue.