Build arcade games for the hack.tez Hackcade. ESM SDK reference, two-way postMessage protocol, identity examples, anti-cheat constraints, local sandbox, and a working example.
Hackcade games are static web bundles (HTML/JS/CSS/assets) that run inside a sandboxed iframe served from IPFS. The platform passes the player's hack.tez identity into the game and accepts score reports back.
The SDK is intentionally tiny (~150 lines). It does not ship a renderer, physics, audio, or game loop — pick any tools you like (vanilla canvas, Phaser, PixiJS, Kaboom.js, plain DOM). The SDK is just the umbilical to the platform: identity in, score out, lifecycle in both directions.
ESM only. The SDK is a JavaScript module. Import it with
<script type="module">. There is no globalwindow.hackcade.
The SDK and a starter template live in the hack-tez repo under hackcade/. Direct downloads:
| File | Direct (raw) URL |
|---|---|
| Canonical SDK | https://raw.githubusercontent.com/skullzarmy/hack-tez/main/hackcade/sdk/hackcade-sdk.js |
| TypeScript types | https://raw.githubusercontent.com/skullzarmy/hack-tez/main/hackcade/sdk/hackcade-sdk.d.ts |
Template index.html | https://raw.githubusercontent.com/skullzarmy/hack-tez/main/hackcade/template/index.html |
Template style.css | https://raw.githubusercontent.com/skullzarmy/hack-tez/main/hackcade/template/style.css |
Template game.js | https://raw.githubusercontent.com/skullzarmy/hack-tez/main/hackcade/template/game.js |
| Browse template dir | https://github.com/skullzarmy/hack-tez/tree/main/hackcade/template |
| Reference game (whack-a-reggie) | https://github.com/skullzarmy/hack-tez/tree/main/hackcade/games/whack-a-reggie |
# scaffold a new game in one go
mkdir my-cool-game && cd my-cool-game
for f in index.html style.css game.js; do
curl -O "https://raw.githubusercontent.com/skullzarmy/hack-tez/main/hackcade/template/$f"
done
curl -O https://raw.githubusercontent.com/skullzarmy/hack-tez/main/hackcade/sdk/hackcade-sdk.js
| Requirement | Detail |
|---|---|
| Entry point | index.html at the bundle root |
| Max zip size | 5 MB compressed |
| Max uncompressed | 25 MB total |
| Max files | 200 entries |
| Max per file | 4 MB |
| Allowed extensions | html, htm, js, mjs, css, json, png, jpg, jpeg, gif, webp, svg, wav, mp3, ogg, woff, woff2, ttf, txt, map |
| Static only | No server-side code; iframe runs with sandbox="allow-scripts" (no same-origin, no top-nav, no popups) |
| SDK | hackcade-sdk.js is auto-injected by the platform — do not bundle a different copy; submission overwrites it with the canonical SDK |
The platform will:
__MACOSX/, .DS_Store, ._*).<script type="module" src="hackcade-sdk.js"></script> into index.html if missing.hackcade-sdk.js with the canonical SDK so submitters can't tamper with player identity or session tokens.interface HackcadePlayer {
domain: string; // "skull.hack.tez" — empty for guests
label: string; // "skull" — "guest" for guests
address: string; // "tz1..." — empty for guests
avatarUrl: string; // profile picture or hackatar URL
hackatarUrl: string; // generative hackatar (always present for authed players)
}
type HackcadeEventName = "init" | "start" | "pause" | "resume" | "visibility";
interface HackcadeGameOverOptions {
durationSeconds?: number;
durationMs?: number;
metadata?: Record<string, unknown>;
}
interface HackcadeSDK {
readonly events: EventTarget; // typed lifecycle events
readonly player: HackcadePlayer | null; // null until ready
readonly session: string | null;
readonly isReady: boolean;
ready(): Promise<HackcadePlayer>;
getPlayer(): HackcadePlayer | null;
isGuest(): boolean;
greeting(): string; // "Hi, skull.hack.tez" or "Hi, guest"
updateScore(score: number): void;
gameOver(finalScore: number, options?: HackcadeGameOverOptions): void;
on(event: HackcadeEventName, handler: (e: Event) => void): () => void;
}
declare const sdk: HackcadeSDK;
export default sdk;
export const events: EventTarget;
<script type="module">
import sdk from "./hackcade-sdk.js";
// Wait for the platform to send identity.
const player = await sdk.ready();
// Use it however you want.
document.getElementById("hi").textContent = sdk.greeting(); // "Hi, skull.hack.tez"
if (player.hackatarUrl) {
const img = new Image();
img.src = player.hackatarUrl;
document.body.appendChild(img);
}
</script>
import sdk from "./hackcade-sdk.js";
let score = 0;
const startedAt = Date.now();
function tap() {
score += 1;
sdk.updateScore(score); // shown in platform chrome above the iframe
}
function endRound() {
sdk.gameOver(score, {
durationMs: Date.now() - startedAt,
metadata: { difficulty: "normal" }, // anything JSON-serialisable
});
}
Note:
gameOvertakes the final score as the first argument and an options object as the second. The options object can carrydurationSecondsORdurationMs(the platform converts to ms server-side) plus arbitrary metadata.
The platform may pause your game (e.g. the player swipes the leaderboard panel open). Your game should respect it:
import sdk from "./hackcade-sdk.js";
let paused = false;
sdk.on("pause", () => {
paused = true;
myAudio.pause();
});
sdk.on("resume", () => {
paused = false;
myAudio.play();
});
// `start` is sent after the player explicitly starts a session from the
// platform side. Most games also have their own start screen — that's fine.
sdk.on("start", () => console.log("platform sent start"));
// `visibility` fires when the iframe is hidden/shown (tab switch, etc).
sdk.on("visibility", (e) => {
// CustomEvent with detail: { hidden: boolean }
console.log("visible:", !e.detail.hidden);
});
sdk.on(event, handler) is sugar for sdk.events.addEventListener(event, handler) and returns an unsubscribe function. You can also use sdk.events directly if you prefer.
The SDK is a thin wrapper around window.parent.postMessage. The two-way protocol:
Game → Platform Platform → Game
───────────────── ─────────────────────
hackcade:ready ───► hackcade:init { player, sessionId }
hackcade:start
hackcade:pause
hackcade:resume
hackcade:visibility { hidden }
hackcade:score ───►
hackcade:gameover ───►
If you want to bypass the SDK (rare), here is the raw protocol:
type ParentMessage =
| { type: "hackcade:init"; player: HackcadePlayer; sessionId: string }
| { type: "hackcade:start" }
| { type: "hackcade:pause" }
| { type: "hackcade:resume" }
| { type: "hackcade:visibility"; hidden: boolean };
type GameMessage =
| { type: "hackcade:ready" }
| { type: "hackcade:score"; score: number; sessionId: string }
| { type: "hackcade:gameover"; score: number; sessionId: string; metadata?: object };
The IPFS gateway origin is shared with every IPFS-hosted page in the world, so the platform validates messages by sessionId, not by event.origin. The SDK echoes the sessionId for you automatically.
You don't need to submit to test. The platform ships a local sandbox at /arcade/sandbox — drop your zip in, it runs entirely in your browser. Same SDK injection, same iframe sandboxing, plus:
hackcade:* message in both directions.The sandbox is also embedded into the Submit form — once you pick a zip, click ▶ Preview locally before hitting Submit.
Most hack.tez users are on phones. Build mobile-first:
pointerdown/touchstart rather than click for responsiveness.devicePixelRatio for crisp rendering.:hover states.<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />.A minimal tap-the-target game that reads identity, reports score live, and submits a final score after 30 seconds:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>Tap Trainer</title>
<style>
html, body { margin: 0; height: 100%; background: #0a0e0d; color: #e6fff8;
font-family: system-ui, sans-serif; user-select: none; touch-action: manipulation; }
#stage { position: fixed; inset: 0; display: flex; flex-direction: column; }
.hud { display: flex; justify-content: space-between; padding: 0.75rem 1rem;
background: rgba(0,0,0,0.35); font-weight: 700; }
#score { color: #00ffc8; font-variant-numeric: tabular-nums; }
.center { flex: 1; display: flex; align-items: center; justify-content: center; }
#target { width: 40vmin; height: 40vmin; border-radius: 50%; background: #00ffc8;
color: #000; display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 1.5rem; }
#target:active { transform: scale(0.92); }
</style>
</head>
<body>
<div id="stage">
<div class="hud"><span id="player">…</span><span id="score">0</span></div>
<div class="center"><div id="target">TAP</div></div>
</div>
<script type="module">
import sdk from "./hackcade-sdk.js";
const player = await sdk.ready();
document.getElementById("player").textContent = sdk.greeting();
let score = 0;
const t0 = Date.now();
const target = document.getElementById("target");
target.addEventListener("pointerdown", () => {
score += 1;
document.getElementById("score").textContent = String(score);
sdk.updateScore(score);
}, { passive: true });
sdk.on("pause", () => target.style.opacity = "0.4");
sdk.on("resume", () => target.style.opacity = "1");
setTimeout(() => sdk.gameOver(score, { durationMs: Date.now() - t0 }), 30_000);
</script>
</body>
</html>
maxPossibleScore and maxScorePerSecond realistically when you submit. The server enforces them.sessionId, so "remote control" attacks against your game's scoring code are blocked at the platform layer.updateScore calls in your own game logic if you want a smoother chrome readout — every call is a postMessage.index.html should be at the root of the zip — a single nested top-level dir is auto-stripped)./arcade/submit while connected with your hack.tez wallet.maxPossibleScore / maxScorePerSecond.admin.hack.tez review.Updates work the same way: from your game's page, click Update Game, upload a new zip, optionally request a leaderboard wipe. The current version keeps serving until the new one is approved.