Ship a game on Forked
Everything you need to get a browser game uploaded, reviewed, and live on the Forked node network, with Discord login and durable leaderboards built in. No servers to run, no netcode to write.
What this is
Forked hosts browser games on a decentralized node network. You upload a zip of a game that runs in the browser. We scan it, a human reviews it, and then it goes live at its own link. Two things come free and are the main reason to host here instead of dropping a zip on any static host:
- Discord login for your players, shared across every game on Forked. You never touch OAuth or store a password.
- Durable leaderboards that survive restarts and show real Discord names, with no database or backend for you to run.
Quick start
- Build a browser game with an
index.htmlat the root of the folder. - Add one line to include the SDK:
<script src="/forked-sdk.js"></script>. - Zip the folder (the files at the top level, not a folder-inside-a-folder).
- Go to the upload page, log in with Discord, and drop the zip.
- It shows as pending. A moderator plays it, then approves it.
- It goes live at
https://vibe.forked.gg/g/<your-game-id>. Share that link.
Packaging your game
The zip
index.htmlmust be at the root of the zip. When someone plays your game, Forked serves that file first. If it is nested inside a folder, the game will not be playable.- Put all your files inside the zip: scripts, images, audio, fonts, everything. Use
relative paths (
./sprites/hero.png,game.js). - Max size is 200 MB per game. Compress big art and audio.
<script src="https://cdn.somewhere.com/three.js"> or a font pulled from Google Fonts
will silently fail and your game will not start. Download those libraries and include them in your zip
instead, then reference them with a relative path.
Modern JavaScript is fine
ES modules work. If your game does import * as THREE from './three.module.js', that is
supported, as long as three.module.js is in your zip and you load your entry script as a module
(<script type="module" src="game.js"></script>). Canvas, WebGL, WebAudio, and
gamepad input all work. There is no server-side code and no build step on our end, we serve exactly the files
you upload.
Add the Forked SDK
The SDK is one script, served from Forked, that gives your game login and leaderboards. Add it before your own scripts:
<script src="/forked-sdk.js"></script>
<script type="module" src="game.js"></script>
It exposes a global window.forked. Everything is promise-based. If your game is opened outside
Forked, the calls simply reject, so guard with a try/catch and your game still runs standalone.
// wait until the bridge is ready, then see who is playing
await forked.ready();
const me = await forked.whoami(); // { player, displayName } or null if not logged in
if (me) console.log("playing as", me.displayName);
Player login
Players sign in with Discord. You do not build any of it. Call forked.login() from a button,
and the whole tab goes to Discord and comes back signed in. Check whoami() when your game loads
to see if they are already logged in.
document.getElementById("login-btn").onclick = () => forked.login();
// on load
const me = await forked.whoami();
if (me) {
showHud("Welcome back, " + me.displayName);
} else {
showLoginButton(); // players can still play logged-out; they just can't post scores
}
Login is optional for playing. It is required only to post a score (so every score has a real person behind it). Reading a leaderboard never needs login.
Scoreboards
This is the part most games want. A scoreboard on Forked is:
- Durable. Scores are saved and survive coordinator restarts. You do not run a database.
- Named by you. A game can have as many boards as it needs, each identified by a
boardIdstring you pick ("highscore","level-3-time","endless"). Scores on different boards are separate. - Discord names, not raw ids. Each row carries a
handle, the player's real Forked / Discord display name, resolved on our side from their verified login. Showhandle; fall back toplayeronly if a name has not resolved yet. - Best-score-per-player, highest wins. Each player keeps their single best score on a board. Submitting a lower score is a harmless no-op.
Submit a score
// the player must be logged in; boardId is any string you choose
const result = await forked.submitScore("highscore", 4820);
// result: { ok: true, improved: true|false, standing: { rank, score, percentile, ... } }
if (result.ok && result.improved) {
showToast("New personal best! Rank #" + result.standing.rank);
}
Show the leaderboard
const board = await forked.getLeaderboard("highscore", { limit: 10 });
// board.rows: [ { player, handle, score, rank }, ... ]
for (const row of board.rows) {
addRow(row.rank, row.handle || "player", row.score); // show the Discord name
}
// where does the current player sit?
const me = await forked.getStanding("highscore");
// me: { player, handle, score, rank, totalPlayers, percentile } or null
if (me) showYourRank("#" + me.rank + " of " + me.totalPlayers);
Limits worth knowing
| Thing | Limit |
|---|---|
| Boards per game | 200 distinct boardIds |
| Submit rate per player, per board | ~30 per minute (extra submits are dropped) |
| Score value | any finite number up to 1e15 in magnitude |
| Ranking | higher wins, ties share the better rank |
Events
There is no separate "event" object with a start and stop button today. You run an event using boards, which works well and keeps a permanent history. The key idea: a board is an event.
Starting an event
Pick a boardId that names the event, ideally with a date so it is unique and you keep past
events forever:
// during your weekend tournament, submit + read the event's own board
const EVENT = "weekend-cup-2026-07-26";
await forked.submitScore(EVENT, score);
const standings = await forked.getLeaderboard(EVENT, { limit: 20 });
Because boards are durable, last month's "weekend-cup-2026-06-28" board is still readable, so
you can show a hall of fame of past winners for free.
Stopping an event
Your game decides when an event is over. When your deadline passes, stop calling
submitScore for that board and switch your UI to read-only, showing the final standings with
getLeaderboard. A simple client-side check does it:
const EVENT = "weekend-cup-2026-07-26";
const ENDS = Date.parse("2026-07-28T23:59:00Z");
const live = Date.now() < ENDS;
if (live) await forked.submitScore(EVENT, score); // event running
const finalBoard = await forked.getLeaderboard(EVENT, { limit: 20 }); // always readable
Review and going live
Nothing you upload is public until a human approves it. Here is the whole path:
- You upload a zip. It is scanned and stored, and shows as pending in your "your games" list.
- A Forked moderator opens it in a sandbox and actually plays it.
- They approve it (now live), reject it, or, later, take it down (a takedown is permanent).
- Once approved, your game is live at
https://vibe.forked.gg/g/<id>and the play link appears next to it in your list.
Approvals are done by hand right now, so give it a little time. Make the moderator's job easy: a game that starts cleanly and does not need external anything sails through.
Updating your game
Fixed a bug, added a level, tuned the difficulty? You can ship a new version without taking your game offline and without losing your scoreboards.
On the upload page, find your game under Your games and hit Update, then pick a new zip. Here is what happens:
- Your current version stays live the whole time. Players keep playing it.
- The new zip is scanned and stored, and goes into the moderation queue as an update. Your game shows an update in review tag in your list.
- A moderator plays the new version in a sandbox (it is not public yet) and approves or rejects it.
- On approval, the new version replaces the old one at the exact same link
(
/g/<id>). Nothing about your URL changes, and your leaderboards carry over untouched. On rejection, your live game is left exactly as it was.
A few rules to know:
- Same zip rules as a first upload. The new zip still needs
index.htmlat the root and every asset self-hosted. An update that would make the game unplayable (no rootindex.html) is refused up front, so an approval can never break your live game. - One update in review at a time. Wait for the current one to be approved or rejected before staging another. Need to change what is in review? Let it get rejected, then upload again.
- Both versions count against your storage quota while an update is in review (the old and the new both sit on the network until the swap).
- Already-open players may see the old files briefly. A browser that loaded the old version can cache its assets for a few minutes; a refresh picks up the update. New players get the new version immediately.
Your scoreboards live at the game level, not the version level, so a leaderboard set up as an event
(a boardId, see Events) keeps every score across an update.
What breaks a game
| Symptom | Cause & fix |
|---|---|
| Blank screen / nothing happens on Play | Your game loads something from an external URL (a CDN script, a Google font, an image on another host). The sandbox blocks it. Put every file in your zip and use relative paths. |
| Game not playable after approval | index.html is not at the root of the zip.
Re-zip so index.html is at the top level. |
| Scores do nothing | The player is not logged in. submitScore needs login. Call
forked.login() first. |
Leaderboard shows discord:12345 style ids | Show row.handle, not
row.player. player is the stable id; handle is the display name. |
| Save data shared between games | Games share browser storage on the play domain, so do not
keep secrets in localStorage. Per-game isolated storage is a planned improvement. |
SDK reference
| Call | Returns | Notes |
|---|---|---|
forked.ready() | { framed: true } | Resolves when the bridge is up. |
forked.whoami() | { player, displayName } or null | Who is logged in. null if not. |
forked.login() | navigates the tab | Sends the player to Discord, then back. Re-check whoami() on load. |
forked.submitScore(boardId, score) | { ok, improved, standing } or { error } | Needs login. improved is true only on a new best. |
forked.getLeaderboard(boardId, {limit}) | { rows: [{ player, handle, score, rank }] } | Public. Default limit 10, max 1000. |
forked.getStanding(boardId) | { player, handle, score, rank, totalPlayers, percentile } or null | The logged-in player's own position. |
Questions, or want a server-enforced event freeze or tournament-grade scoring? Reach out and we will help. Ready to ship? Upload your game →