Every Deadlock tier list on the internet shows you one person’s opinion, updated whenever that person feels like it. Sites like tracklock.gg and statlocker.gg solve part of that problem by pulling win-rate data automatically, but they still don’t let your own Discord server or Deadlock club settle an argument with a live vote count. This tutorial builds that missing piece: a small full-stack voting app where anyone with a link can rank all 38 heroes, and the server turns those votes into a defensible S-through-F tier list using the same statistical method Reddit uses to rank comments.
By the end you’ll have a working Node.js and SQLite app you can run locally, deploy for free, and re-seed after every Deadlock patch. We’ll build it against the current August 12, 2026 balance update, which touched 13 heroes including Apollo, Vyper, Billy, and Wraith.
This is a good weekend project even if you’ve never shipped a backend before. Nothing here needs a framework heavier than Express, no cloud account beyond a free hosting tier, and no prior database experience past knowing what a table and a row are. If you can follow a recipe, you can follow these 12 steps.
What You’ll Build
The finished project is a small Express API backed by a SQLite database, plus a single static HTML page that talks to it. Voters click a hero and pick S, A, B, C, D, or F. The server stores each vote, computes a Wilson score confidence interval per hero, and re-ranks the whole roster in real time. An admin endpoint lets you wipe votes and reload the hero list whenever Valve ships a patch that shuffles the meta. Everything runs on free-tier hosting, and the whole stack fits in under 400 lines of code.
- A REST API with five endpoints: list heroes, cast a vote, get rankings, reset votes, and reload the roster
- A SQLite database with two tables: heroes and votes
- Tier assignment based on a lower-bound confidence score, not raw win percentage
- Duplicate-vote protection tied to IP and hero, plus basic rate limiting
- A static frontend you can host anywhere, including GitHub Pages
The stack is deliberately small. Express and SQLite were picked over heavier options like NestJS or a managed Postgres cluster because a Deadlock club’s tier-list tool doesn’t need to survive a traffic spike from Hacker News, it needs to be something you can build in an evening and maintain in five minutes a week. Every piece of this project scales up cleanly later (swap SQLite for Postgres, add Redis for rate limiting, add auth for stronger vote integrity) without a rewrite, so nothing here is a dead end if your community grows.
Prerequisites and Tools You’ll Need
You don’t need prior backend experience beyond basic JavaScript, but you should be comfortable running commands in a terminal. Install the tools below before starting Step 1.
| Tool | Version Used Here | Why You Need It |
|---|---|---|
| Node.js | 22 LTS | Runs the Express server and build scripts |
| npm | 10.x (bundled with Node 22) | Installs Express, better-sqlite3, and dev dependencies |
| SQLite | 3.45+ (via better-sqlite3) | Stores heroes and votes on disk, no separate DB server |
| Express | 4.19+ | Handles routing for the REST API |
| Git | 2.40+ | Version control and deployment via Render or Railway |
| A code editor | VS Code or similar | Editing server.js and the frontend HTML |
You’ll also want Node’s official documentation open in a tab, and a free account on a host like Render if you plan to follow Step 11. Nothing here costs money to build or run at hobby scale. If you already run a Postgres database for another project, the SQL in this tutorial is close enough to standard that you can adapt the schema in Step 2 with only minor syntax changes. SQLite is the default here because it needs zero setup, not because it’s a hard requirement.
The Deadlock Meta Right Now: Patch 08-12-2026
Before you seed a database with hero data, it helps to know what you’re working with. Deadlock sits at 38 playable heroes as of the August 12, 2026 minor update, which was almost entirely a balance pass: 13 hero adjustments, zero new items, and zero general gameplay changes, alongside a matchmaking revamp. Apollo and Vyper came out as the clearest winners, while Billy and Wraith took the sharpest nerfs. Apollo’s Riposte melee resist reduction, for example, jumped from -22% to -25%, making his counter-engage hit harder in close fights.
Rankings that lean only on win rate can mislead you when a hero has a small sample size. That’s exactly the problem a voting app with a proper confidence interval solves better than a raw leaderboard.
| Rank Ladder Tier | Position (Low to High) | Notes for August 2026 |
|---|---|---|
| Initiate → Seeker | 1-2 | Entry ranks, largest new-player pool |
| Acolyte → Sentinel | 3-4 | Mechanics-focused matches, low rotation discipline |
| Mystic → Ritualist | 5-6 | Mid-ladder, where most active players sit (Mystic 6 held about 3.2% of the population in mid-2026 snapshots) |
| Emissary → Oracle | 7-8 | Coordinated rotations, item timing matters more |
| Phantom → Ascendant | 9-10 | High-elo, closer to the tracked win-rate datasets |
| Eternus | 11 | Top of the ladder |
Deadlock’s 11-rank ladder runs from Initiate to Eternus. Existing tier tools such as statlocker.gg publish their own win-rate cutoffs (S-tier at 54%+, A-tier at 52%, sliding down to F below 45%), which is a reasonable starting point but treats a hero with 40 games the same as one with 4,000. The app you’re about to build fixes that by weighting confidence, not just the raw percentage.
High-elo tracked comps in August 2026 give a sense of how wide the spread can get. One tracked composition running Infernus, Lady Geist, Haze, Mo & Krill, Lash, and Paige posted 92 wins against 30 losses across 122 matches, a roughly 75% win rate, while other five-hero groupings built around Seven, Abrams, Dynamo, Drifter, Graves, and Celeste sat in a similar 72-75% band. None of these comps carry a large pick rate, which is exactly the small-sample trap a confidence-weighted vote system is built to catch. Community sentiment threads, including a widely discussed Reddit tier-list debate, still argue over whether Doorman belongs above or below Abrams, which is a good example of the kind of dispute a voting app settles with a number instead of another forum reply.
Step 1: Set Up Your Project Folder and Initialize npm
Create a folder, initialize npm, and install the three packages you need: Express for routing, better-sqlite3 for a fast synchronous database layer, and cors so your static frontend can call the API from a different origin during local development.
mkdir deadlock-tier-vote && cd deadlock-tier-vote
npm init -y
npm install express better-sqlite3 cors
npm install --save-dev nodemon
mkdir data public
Open package.json and add a start script so you can run npm run dev during development:
{
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
}
Step 2: Design the SQLite Database Schema
You need two tables. The heroes table holds the 38 current heroes plus a slug and role. The votes table records one row per vote, so you never lose granularity by pre-aggregating too early. Create a file called schema.sql:
CREATE TABLE IF NOT EXISTS heroes (
id INTEGER PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hero_id INTEGER NOT NULL,
tier TEXT NOT NULL CHECK (tier IN ('S','A','B','C','D','F')),
voter_hash TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (hero_id) REFERENCES heroes(id)
);
CREATE UNIQUE INDEX IF NOT EXISTS one_vote_per_hero
ON votes(hero_id, voter_hash);
The unique index on (hero_id, voter_hash) is what stops one visitor from voting for the same hero twice. You’ll generate voter_hash from a combination of IP address and a client-side token in Step 9.
Step 3: Build the Express API Server
Create server.js and wire up the database connection, load the schema on boot, and set up the base Express app with JSON parsing and CORS enabled.
const express = require('express');
const cors = require('cors');
const Database = require('better-sqlite3');
const fs = require('fs');
const app = express();
const db = new Database('data/deadlock.db');
db.exec(fs.readFileSync('schema.sql', 'utf8'));
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
app.get('/api/heroes', (req, res) => {
const heroes = db.prepare('SELECT * FROM heroes ORDER BY name').all();
res.json(heroes);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Run npm run dev and hit http://localhost:3000/api/heroes. You should get back an empty array, since you haven’t seeded any heroes yet. That’s the next step.
Step 4: Seed the 38-Hero Roster
Create seed.js with the current roster as of the August 12, 2026 patch. List each hero with a slug and a rough role tag (Vanguard, Ranged, Support, etc.) so you can filter later if you want to.
const Database = require('better-sqlite3');
const db = new Database('data/deadlock.db');
const heroes = [
['abrams','Abrams','Vanguard'], ['apollo','Apollo','Ranged'],
['bebop','Bebop','Vanguard'], ['billy','Billy','Support'],
['calico','Calico','Melee'], ['celeste','Celeste','Ranged'],
['dynamo','Dynamo','Vanguard'], ['doorman','The Doorman','Support'],
['drifter','Drifter','Melee'], ['graves','Graves','Ranged'],
['greytalon','Grey Talon','Ranged'], ['haze','Haze','Melee'],
['holliday','Holliday','Ranged'], ['infernus','Infernus','Ranged'],
['ivy','Ivy','Support'], ['kelvin','Kelvin','Vanguard'],
['ladygeist','Lady Geist','Ranged'], ['lash','Lash','Melee'],
['mcginnis','McGinnis','Vanguard'], ['mina','Mina','Support'],
['mirage','Mirage','Ranged'], ['mokrill','Mo & Krill','Vanguard'],
['paige','Paige','Support'], ['paradox','Paradox','Ranged'],
['pocket','Pocket','Support'], ['rem','Rem','Melee'],
['seven','Seven','Ranged'], ['shiv','Shiv','Melee'],
['sinclair','Sinclair','Support'], ['victor','Victor','Ranged'],
['vindicta','Vindicta','Ranged'], ['viscous','Viscous','Vanguard'],
['vyper','Vyper','Ranged'], ['warden','Warden','Vanguard'],
['wraith','Wraith','Ranged'], ['yamato','Yamato','Melee'],
['venator','Venator','Melee'], ['silver','Silver','Support']
];
const insert = db.prepare('INSERT OR IGNORE INTO heroes (slug, name, role) VALUES (?, ?, ?)');
const insertMany = db.transaction((rows) => {
for (const row of rows) insert.run(...row);
});
insertMany(heroes);
console.log(`Seeded ${heroes.length} heroes.`);
Run node seed.js. You should see Seeded 38 heroes. in the terminal. Re-run it any time a patch adds or removes a hero, since the OR IGNORE clause means it won’t duplicate existing rows.
Step 5: Implement the Vote Endpoint
Add a POST endpoint that accepts a hero slug and a tier letter, hashes the requester’s IP into a voter identifier, and inserts the vote. Catch the unique-constraint error so a repeat vote returns a clean 409 instead of a stack trace.
const crypto = require('crypto');
app.post('/api/vote', (req, res) => {
const { slug, tier } = req.body;
const hero = db.prepare('SELECT id FROM heroes WHERE slug = ?').get(slug);
if (!hero) return res.status(404).json({ error: 'Unknown hero' });
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const voterHash = crypto.createHash('sha256').update(ip + slug).digest('hex');
try {
db.prepare('INSERT INTO votes (hero_id, tier, voter_hash) VALUES (?, ?, ?)')
.run(hero.id, tier, voterHash);
res.status(201).json({ ok: true });
} catch (err) {
if (err.message.includes('UNIQUE')) {
return res.status(409).json({ error: 'You already voted for this hero' });
}
res.status(500).json({ error: 'Vote failed' });
}
});
Test it with curl before wiring up a frontend, so you can confirm the logic works in isolation:
$ curl -X POST http://localhost:3000/api/vote \
-H "Content-Type: application/json" \
-d '{"slug":"apollo","tier":"S"}'
{"ok":true}
$ curl -X POST http://localhost:3000/api/vote \
-H "Content-Type: application/json" \
-d '{"slug":"apollo","tier":"A"}'
{"error":"You already voted for this hero"}
Step 6: Rank Heroes with the Wilson Score Interval
Raw win percentage lies to you with small samples. A hero with 2 S-tier votes out of 2 looks “better” than one with 340 S-tier votes out of 380, even though the second is obviously the stronger signal. The Wilson score interval fixes this by computing the lower bound of a confidence interval instead of a raw average, which is the same trick Reddit historically used to rank comments. Here, treat “S or A tier” as a positive vote and everything else as negative, then compute the lower bound per hero.
function wilsonLowerBound(positive, total, z = 1.96) {
if (total === 0) return 0;
const p = positive / total;
const denom = 1 + (z * z) / total;
const centre = p + (z * z) / (2 * total);
const margin = z * Math.sqrt((p * (1 - p)) / total + (z * z) / (4 * total * total));
return (centre - margin) / denom;
}
app.get('/api/rankings', (req, res) => {
const rows = db.prepare(`
SELECT h.slug, h.name, h.role,
COUNT(v.id) AS total,
SUM(CASE WHEN v.tier IN ('S','A') THEN 1 ELSE 0 END) AS positive
FROM heroes h
LEFT JOIN votes v ON v.hero_id = h.id
GROUP BY h.id
`).all();
const ranked = rows.map(r => ({
...r,
score: wilsonLowerBound(r.positive, r.total)
})).sort((a, b) => b.score - a.score);
res.json(ranked);
});
A z-value of 1.96 corresponds to a 95% confidence level, which is a reasonable default for a community tool. Heroes with zero votes score 0 and sink to the bottom, which is correct: you don’t have data on them yet.
Step 7: Assign Tiers Automatically (S Through F)
With a sorted score list, split it into six buckets by percentile rather than a fixed score cutoff. Percentile-based buckets keep the tier list balanced even as the vote distribution shifts after a patch.
function assignTiers(ranked) {
const cutoffs = [
{ tier: 'S', pct: 0.10 }, { tier: 'A', pct: 0.25 },
{ tier: 'B', pct: 0.30 }, { tier: 'C', pct: 0.20 },
{ tier: 'D', pct: 0.10 }, { tier: 'F', pct: 0.05 }
];
let index = 0;
const total = ranked.length;
return cutoffs.flatMap(({ tier, pct }) => {
const count = Math.round(total * pct);
const slice = ranked.slice(index, index + count).map(h => ({ ...h, tier }));
index += count;
return slice;
});
}
Chain this after the sort in your /api/rankings handler and you’ll return a hero list that’s already labeled S through F, ready for the frontend to render without any client-side math.
Example Output: What the Rankings API Returns
Before wiring up a frontend, it helps to see exactly what shape of data you’re working with. After casting a handful of test votes for a few heroes, a call to GET /api/rankings returns something close to this:
[
{
"slug": "apollo",
"name": "Apollo",
"role": "Ranged",
"total": 42,
"positive": 35,
"score": 0.699,
"tier": "S"
},
{
"slug": "vyper",
"name": "Vyper",
"role": "Ranged",
"total": 38,
"positive": 29,
"score": 0.622,
"tier": "S"
},
{
"slug": "billy",
"name": "Billy",
"role": "Support",
"total": 11,
"positive": 2,
"score": 0.091,
"tier": "D"
},
{
"slug": "silver",
"name": "Silver",
"role": "Support",
"total": 0,
"positive": 0,
"score": 0,
"tier": "F"
}
]
Notice that Apollo sits at the top despite having roughly the same positive rate as Vyper, purely because it has more total votes backing that rate. Billy, matching this month’s nerf, drops toward the bottom on both count and confidence. Silver, a hero nobody has voted on yet, correctly lands in F rather than getting an arbitrary default score. That’s the entire point of using a lower-bound confidence score instead of a plain average: the ranking reflects both how good a hero looks and how sure you can be about it.
Step 8: Build the Frontend Voting Interface
Keep the frontend deliberately plain: a grid of hero cards, six tier buttons per card, and a live rankings table underneath. Save this as public/index.html.
<div id="heroes"></div>
<table id="rankings"></table>
<script>
async function loadHeroes() {
const heroes = await fetch('/api/heroes').then(r => r.json());
document.getElementById('heroes').innerHTML = heroes.map(h => `
<div class="card">
<span>${h.name}</span>
${['S','A','B','C','D','F'].map(t =>
`<button onclick="vote('${h.slug}','${t}')">${t}</button>`).join('')}
</div>`).join('');
}
async function vote(slug, tier) {
const res = await fetch('/api/vote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, tier })
});
if (res.ok) loadRankings();
else alert((await res.json()).error);
}
async function loadRankings() {
const ranked = await fetch('/api/rankings').then(r => r.json());
document.getElementById('rankings').innerHTML = ranked.map(h =>
`<tr><td>${h.tier}</td><td>${h.name}</td><td>${h.total} votes</td></tr>`).join('');
}
loadHeroes();
loadRankings();
</script>
This is intentionally bare-bones. Once it works, swap the inline styles for a real stylesheet and add sorting, but get the data flow correct first. Everything here runs on the standard Fetch API, so there’s no build step, no bundler, and nothing to install on the frontend side.
Step 9: Block Duplicate Votes and Rate-Limit Requests
The unique index from Step 2 stops the same IP from voting twice for one hero, but it won’t stop someone from scripting 38 rapid-fire votes across the whole roster in a few seconds. Add a lightweight in-memory rate limiter to slow that down without pulling in a separate database like Redis for a hobby project.
const hits = new Map();
function rateLimit(req, res, next) {
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const now = Date.now();
const windowMs = 60_000;
const max = 20;
const record = hits.get(ip) || { count: 0, start: now };
if (now - record.start > windowMs) {
record.count = 0;
record.start = now;
}
record.count += 1;
hits.set(ip, record);
if (record.count > max) {
return res.status(429).json({ error: 'Too many votes, slow down' });
}
next();
}
app.post('/api/vote', rateLimit, (req, res) => { /* existing handler */ });
Twenty votes per minute is enough for a real person to rank the whole roster once, but too slow for a naive bot script hammering the endpoint. If you deploy this publicly, plan to swap the Map for Redis once you have real traffic, since an in-memory map resets whenever the server restarts.
Step 10: Add an Admin Reset Endpoint for New Patches
Deadlock gets balance patches roughly every few weeks. When one lands, you want to wipe votes without losing the hero list, and reload the roster if names were added. Protect this endpoint with a simple shared secret from an environment variable, since a full auth system is overkill here.
app.post('/api/admin/reset', (req, res) => {
const key = req.headers['x-admin-key'];
if (key !== process.env.ADMIN_KEY) {
return res.status(403).json({ error: 'Forbidden' });
}
db.prepare('DELETE FROM votes').run();
res.json({ ok: true, message: 'Votes cleared for new patch' });
});
Set ADMIN_KEY in a .env file locally and as an environment variable on your host. Never hardcode it into server.js or commit it to git.
Step 11: Deploy Your App for Free on Render
Push your project to a GitHub repository, then connect it to Render (or Railway, which works almost identically) as a new Web Service. Set the build command to npm install and the start command to npm start. Add ADMIN_KEY as an environment variable in the dashboard rather than in code.
echo "node_modules/
data/*.db
.env" > .gitignore
git init
git add .
git commit -m "Deadlock tier list voting app"
git branch -M main
git remote add origin https://github.com/YOUR_USERNAME/deadlock-tier-vote.git
git push -u origin main
Free-tier Render instances spin down after inactivity and take a few seconds to wake up on the first request, which is a fair trade for a $0 hosting bill on a side project. If that cold-start delay bothers your users, a paid starter instance removes it.
Step 12: Automate Tier Recalculation with a Cron Job
You don’t need to hit reset by hand every patch. Add a small script that checks Valve’s patch notes feed and pings your own admin endpoint, then schedule it. On Linux or a Render cron job, a crontab entry works fine for a low-stakes hobby tool.
# Run every Monday at 09:00, review new patch notes manually first
0 9 * * 1 curl -X POST https://your-app.onrender.com/api/admin/reset \
-H "x-admin-key: $ADMIN_KEY"
A weekly cadence matches how often Deadlock ships minor updates in 2026. Check Valve’s own Deadlock news page before firing the reset, since not every patch changes enough heroes to justify wiping the vote history.
Common Pitfalls to Avoid
Most of the bugs that show up in a project like this trace back to one of six habits. None of them are hard to fix once you know to look for them, but they’re easy to ship by accident on a first pass.
- Trusting raw win percentage over confidence. A hero with 3 votes and 100% S-tier picks is not actually your best hero. Always use a scoring method that accounts for sample size, like the Wilson lower bound from Step 6.
- Forgetting the unique index. Without the (hero_id, voter_hash) constraint, one refresh-happy visitor can flood a single hero with duplicate votes and skew your whole list.
- Hardcoding the hero roster in the frontend. Keep heroes in the database, not in a JS array baked into index.html, or you’ll edit two places every patch instead of one.
- Skipping rate limiting because “it’s just a hobby project.” Public URLs get scraped and hammered by bots regardless of intent. A 20-line rate limiter costs you nothing and saves your database from garbage data.
- Using client IP alone as a vote key with no salt. On shared networks (offices, dorms, mobile carriers using CGNAT), multiple real people share one IP. Combine IP with a lightweight client cookie for a fairer per-person limit if accuracy matters to you.
- Never re-seeding after a hero-adding patch. If Valve ships a new hero, your voting app silently ignores them until you re-run seed.js with the updated list.
Troubleshooting Guide
Keep this table nearby while you’re wiring up the API for the first time. Almost every error you’ll hit building this project falls into one of these nine buckets, and most of them take under a minute to fix once you know where to look.
| Symptom | Likely Cause | Fix |
|---|---|---|
| Server crashes with “SQLITE_CANTOPEN” | The data/ folder doesn’t exist | Run mkdir data before starting the server |
| /api/heroes returns an empty array | seed.js was never run | Run node seed.js and confirm “Seeded 38 heroes.” prints |
| Every vote returns 409 | Testing repeatedly from the same IP for the same hero | Expected behavior. Vote for a different hero or clear the votes table to retest |
| CORS error in the browser console | Frontend and API running on different origins without cors() enabled | Confirm app.use(cors()) is called before your routes |
| Rankings never change | Votes are landing but the page is showing a stale cached response | Check you’re not caching fetch() results client-side, and disable browser cache during testing |
| Wilson scores all show as 0 | total is 0 in the SQL query, division skipped | Confirm the LEFT JOIN in Step 6 is actually returning vote rows, not filtering them out |
| Render deploy fails on build | better-sqlite3 needs native compilation and the build environment lacks tools | Add a Node engines version pin in package.json, or switch to Railway’s Nixpacks |
| Admin reset returns 403 even with the right key | Trailing whitespace or wrong header casing in the request | Double-check the header name is exactly x-admin-key and the value matches the env var precisely |
| Database resets on every Render deploy | SQLite file lives on ephemeral disk, not a persistent volume | Attach a Render persistent disk, or migrate to a hosted Postgres instance for production use |
Advanced Tips for Power Users
Once the basic voting loop works, a few upgrades make the tool genuinely useful past a weekend project. First, split rankings by rank bracket. Add a rank_bracket column to the votes table and let voters self-report whether they play in Initiate through Ritualist or Emissary through Eternus, then run separate Wilson calculations per bracket. Low-rank and high-rank metas diverge more than people expect, and a single blended list hides that.
Second, track vote history over time instead of just the current snapshot. Rather than deleting rows on reset, add a patch_version column and filter by it, so you can build a small trend chart showing how a hero like Vyper moved from B-tier to S-tier across three patches. That historical view is something none of the static win-rate dashboards currently offer.
Third, if you want to blend community sentiment with real match data, expose a weight parameter in your ranking query that mixes the Wilson score with an external win-rate feed. Community perception and statistical win rate often disagree, most visibly right after a patch when players haven’t adjusted yet, and showing both numbers side by side is more honest than picking one.
Fourth, consider adding a simple audit log instead of hard-deleting rows on every reset. Insert a snapshot into an archive table before running the DELETE in your admin endpoint, tagged with the patch date. Six months in, that gives you a small dataset of how the whole roster moved after every single balance pass, which is more interesting to a Deadlock community than any single week’s ranking on its own.
Voting App vs Static Tier List Tools
This build is meant to sit alongside, not replace, the two other approaches this site has already covered: a drag-and-drop Deadlock Tier List Maker for building your own personal ranking, and a Deadlock Tier List Tracker for following the community consensus as patches land. The table below shows where each one fits.
| Approach | Data Source | Best For | Backend Required |
|---|---|---|---|
| Tier List Maker | Your own drag-and-drop input | A single content creator’s personal opinion | No, browser localStorage only |
| Tier List Tracker | Imported community baseline + your notes | Following the meta as an individual reader | No, static site |
| Voting App (this tutorial) | Aggregated votes from many people, weighted by confidence | A Discord server, club, or team settling arguments with data | Yes, Express + SQLite |
If you only need a personal list, the Maker is faster to set up. If you want to settle a group debate with actual numbers, the voting app in this tutorial is the one worth the extra hour of backend work.
The Complete Working Project
Your finished folder structure should look like this once you’ve completed all 12 steps:
deadlock-tier-vote/
├── data/
│ └── deadlock.db
├── public/
│ └── index.html
├── node_modules/
├── schema.sql
├── seed.js
├── server.js
├── package.json
└── .gitignore
Start it with npm run dev, open http://localhost:3000, cast a few test votes, and watch the rankings table update. From there, deploy following Step 11, share the live URL with your Discord or club, and reset votes with the admin endpoint whenever Valve ships the next balance patch.
Run through a full manual test pass before sharing the link with anyone. Cast a vote for every tier on two or three heroes, confirm the 409 response fires on a repeat vote, hit the admin reset endpoint, and reload /api/heroes to make sure all 38 names still come back. That five-minute check catches most of the mistakes covered in the pitfalls and troubleshooting sections below before a real user ever sees them.
Related Coverage
- Deadlock Tier List Maker: 12 Steps, 30 Min [2026]
- Deadlock Tier List Tracker: 38 Heroes, 12 Steps [2026]
- Apex Legends Tier List 2026: 28 Legends, 12-Step Tracker
- Rainbow Six Siege Stats Tracker Setup: 10 Steps, 30 Min [2026]
- CS2 Dedicated Server: 12 Steps, 30 Min [2026]
- More Esports Coverage
Frequently Asked Questions
Do I need a real database server to run this?
No. better-sqlite3 stores everything in a single file on disk, so there’s no separate database process to install or manage. That’s fine up to a few thousand votes. Past that, migrating to hosted Postgres is straightforward since the SQL is nearly identical.
Why use the Wilson score instead of just averaging votes?
A simple average lets a hero with 2 votes outrank one with 400 votes, which is statistically meaningless. The Wilson lower bound punishes small samples, so a hero needs both a high positive rate and enough votes to earn a top ranking.
How often should I reset votes after a Deadlock patch?
Only reset after patches that meaningfully touch the meta. The August 12, 2026 update adjusted 13 heroes, which is a reasonable bar. A patch that only fixes a bug or two doesn’t need a full vote wipe.
Can I stop people from voting more than once?
You can raise the bar, but you can’t fully guarantee it without requiring login. IP plus hero hashing (Step 2) blocks casual repeat voting. For stronger guarantees, add a lightweight auth layer, such as a Discord OAuth login, before accepting votes.
Will this work for other games besides Deadlock?
Yes. The schema, vote endpoint, and Wilson score logic are game-agnostic. Swap the seed.js hero list for any roster (Overwatch 2 heroes, Valorant agents, Apex Legends) and the rest of the app works unchanged.
Is a free Render or Railway plan enough to run this permanently?
For a hobby-scale tool with a Discord server or small community, yes. Watch for two limits: free instances sleep after inactivity, and their disks are often ephemeral, meaning your SQLite file can reset on redeploy unless you attach a persistent volume.
How is this different from just using statlocker.gg or tracklock.gg?
Those sites rank heroes from aggregate match data pulled from the game itself, which reflects what’s actually winning. This app ranks heroes from what your specific community believes, which is a different and complementary signal, especially useful for a group that wants its own consensus rather than the broader playerbase’s.
What if I want tier colors and hero portraits in the UI?
Add a CSS class per tier letter (S through F) and map them to colors in your stylesheet. For portraits, store an image URL column on the heroes table and reference official Deadlock hero art from Valve’s own site rather than hosting copyrighted images yourself.
What z-value should I use for a smaller community?
1.96 (95% confidence) works well once you have a few hundred votes spread across the roster. For a small Discord server that might only collect 10-20 votes per hero, try a lower z-value like 1.44 (85% confidence) so early rankings aren’t punished quite as harshly for having a thinner sample. Revisit the value once vote counts climb.
Should voters be able to change their vote later?
It’s a reasonable feature to add, but treat it as a deliberate decision rather than an accident. Swap the INSERT in Step 5 for an UPSERT (ON CONFLICT(hero_id, voter_hash) DO UPDATE SET tier = excluded.tier) if you want opinions to update in place as the meta shifts, instead of staying locked to whatever someone thought on day one.




