Creating Mini Games for NFT Metadata
This guide explains how to create standalone HTML games using Phaser.js that can be embedded as animation_url in NFT metadata on platforms like OpenSea.
Table of Contents
Overview
Games for NFT metadata must be standalone HTML files that work in OpenSea's iframe viewer. Each game should be:
- Self-contained: All code is embedded in a single HTML file
- CORS-enabled: Assets are served with proper CORS headers
- Iframe-compatible: Works within OpenSea's restricted iframe environment
- Mobile-friendly: Includes touch controls for mobile devices
The HTML file is referenced in NFT metadata as the animation_url field and will be displayed in an iframe on platforms like OpenSea.
Requirements for OpenSea Compatibility
Technical Requirements
- Standalone HTML: Everything must be in one HTML file (no external JS/CSS files)
- CORS Headers: All assets must be served with
Access-Control-Allow-Origin: * - CDN Fallbacks: Phaser.js should load from multiple CDN sources with fallbacks
- No External Dependencies: Except Phaser.js from CDN (no npm packages in the HTML)
- Meta Tags: Include Open Graph and NFT-specific meta tags for previews
OpenSea-Specific Constraints
- Games run in an iframe with restricted permissions
- Audio requires user interaction before playing (browser autoplay policies)
- Keyboard input may be limited or unavailable on some devices
- Touch controls are essential for mobile users
- Viewport size varies - use responsive scaling
Complete HTML Template
Here's a complete working example based on the Gotchi Tower minigame (a Doodle Jump-style platformer). You can customize it for your own game:
Note: Replace https://gotchi.lol with your actual domain URL where assets are hosted.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tower Game - Gotchi Minigames</title>
<!-- Open Graph / NFT Metadata (for thumbnails/previews) -->
<meta property="og:title" content="Tower Game">
<meta property="og:description" content="Play Tower - A Gotchi Minigame">
<meta property="og:image" content="https://gotchi.lol/api/mini-game/2.png">
<meta property="og:type" content="website">
<!-- NFT-specific meta tags -->
<meta name="nft:name" content="Tower Game">
<meta name="nft:description" content="Play Tower - A Gotchi Minigame">
<!-- Structured data for NFT marketplaces -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Game",
"name": "Tower Game",
"description": "Play Tower - A Gotchi Minigame",
"image": "https://gotchi.lol/api/mini-game/2.png"
}
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100%;
height: 100vh;
overflow: hidden;
background: #1a1a1a;
display: flex;
justify-content: center;
align-items: center;
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
#tower-game-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
margin: 0;
padding: 0;
position: relative;
}
#tower-game-container > canvas {
display: block;
margin: 0 auto !important;
}
</style>
</head>
<body>
<div id="tower-game-container"></div>
<!-- Multiple CDN fallbacks for Phaser -->
<script>
(function() {
const cdns = [
'https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/phaser/3.90.0/phaser.min.js'
];
function tryLoad(index) {
if (index >= cdns.length) {
console.error('Failed to load Phaser from all CDNs');
document.getElementById('tower-game-container').innerHTML = '<div style="color: white; text-align: center; padding: 50px;">Failed to load game library. Please refresh.</div>';
return;
}
const script = document.createElement('script');
script.src = cdns[index];
script.onload = () => initGame();
script.onerror = () => tryLoad(index + 1);
document.head.appendChild(script);
}
tryLoad(0);
})();
function initGame() {
// Game constants
const GAME_WIDTH = 1500;
const GAME_HEIGHT = 2000;
const PLAYER_SIZE = 100;
const PLATFORM_WIDTH_MIN = 150;
const PLATFORM_WIDTH_MAX = 250;
const PLATFORM_HEIGHT = 20;
const PLATFORM_SPACING_MIN = 150;
const PLATFORM_SPACING_MAX = 250;
const MAX_HORIZONTAL_JUMP_DISTANCE = 400; // Maximum horizontal distance player can jump between platforms
// Platform types
const PlatformType = {
NORMAL: 'normal',
ONEWAY: 'oneway',
DISAPPEARING: 'disappearing'
};
// Platform interface
class Platform {
constructor(graphics, x, y, width, type, body, hasBeenTouched) {
this.graphics = graphics;
this.x = x;
this.y = y;
this.width = width;
this.type = type;
this.body = body;
this.hasBeenTouched = hasBeenTouched || false;
}
}
// Tower Scene
class TowerScene extends Phaser.Scene {
constructor() {
super({ key: 'TowerScene' });
}
preload() {
// Set crossOrigin for all loads to ensure CORS works in OpenSea iframes
this.load.setCORS('anonymous');
// Load player sprite sheet
const spriteUrlToLoad = 'https://gotchi.lol/api/game-assets/sprites/player/gotchi.png';
this.load.spritesheet('player', spriteUrlToLoad, {
frameWidth: 100,
frameHeight: 100
});
// Load background music
this.load.audio('bgMusic', [
'https://gotchi.lol/api/game-assets/music/inthezone.ogg',
'https://gotchi.lol/api/game-assets/music/inthezone.mp3'
]);
}
create() {
// Initialize game state variables
this.platforms = [];
this.score = 0;
this.highestY = 0;
this.lastCheckpointScore = 0; // Track last checkpoint score milestone
this.isGameOver = false;
this.gameStarted = false;
this.musicStarted = false;
this.canJump = true;
this.leftButtonPressed = false;
this.rightButtonPressed = false;
this.highestCameraY = 0; // Track highest camera position (for Doodle Jump style)
// Set pixel-perfect rendering
this.cameras.main.setRoundPixels(true);
// Set texture filtering for pixel art
if (this.textures.exists('player')) {
this.textures.get('player').setFilter(Phaser.Textures.FilterMode.NEAREST);
}
// Create player animations
this.createAnimations();
// Start player at bottom center
const startX = GAME_WIDTH / 2;
const startY = GAME_HEIGHT - 200;
this.startY = startY;
this.player = this.physics.add.sprite(startX, startY, 'player');
this.player.setScale(2);
this.player.setDepth(10);
this.player.play('idle');
// Set player physics body
// Only collide with world bounds on left, right, and bottom - allow infinite upward movement
if (this.player.body) {
// Set collision box to 12x30 with offset (gotchi is smaller than 100x100 frame)
this.player.body.setSize(12, 30);
this.player.body.setOffset(44, 40);
// Set custom world bounds: infinite upward, bounded on sides and bottom
this.physics.world.setBounds(0, -Infinity, GAME_WIDTH, Infinity);
// Enable collision with world bounds (left, right, bottom only - no top)
this.player.setCollideWorldBounds(true);
// Note: Phaser doesn't support disabling top collision directly,
// but with infinite bounds, player can move upward indefinitely
}
// Create platforms groups
this.platformsGroup = this.physics.add.staticGroup();
this.oneWayPlatformsGroup = this.physics.add.staticGroup();
// Generate initial platforms
this.generateInitialPlatforms();
// Set up physics collisions for normal and disappearing platforms
this.physics.add.collider(this.player, this.platformsGroup, this.handlePlatformCollision, undefined, this);
// Set up one-way platform collisions with process callback
// Process callback returns false to allow passthrough when moving up
this.physics.add.collider(
this.player,
this.oneWayPlatformsGroup,
this.handleOneWayCollision,
(player, platform) => {
// Only process collision if player is falling (velocity.y > 0)
// This allows passthrough when jumping up
return this.player.body ? this.player.body.velocity.y > 0 : false;
},
this
);
// Set up camera
this.setupCamera();
// Set up input
this.setupKeyboardControls();
this.setupTouchControls();
// Create music button
this.createMusicButton();
// Create score text
this.scoreText = this.add.text(50, 50, 'Score: 0', {
fontSize: '50px',
fontFamily: 'Arial',
color: '#ffffff',
stroke: '#000000',
strokeThickness: 8
});
this.scoreText.setDepth(100);
this.scoreText.setScrollFactor(0);
// Initialize background music (don't auto-play)
try {
this.bgMusic = this.sound.add('bgMusic', { loop: true, volume: 0.5 });
} catch (err) {
console.log('Error initializing music:', err);
}
}
createAnimations() {
if (!this.anims.exists('idle')) {
this.anims.create({
key: 'idle',
frames: this.anims.generateFrameNumbers('player', {
start: 0,
end: 5
}),
frameRate: 6,
repeat: -1
});
}
}
createGradientBackground() {
// Create horizontal gradient from black (#000000) to blue-purple (#5A00FF)
const bgGraphics = this.add.graphics();
bgGraphics.setDepth(0);
// Black color components (left side)
const blackR = 0x00;
const blackG = 0x00;
const blackB = 0x00;
// Blue-purple color components (right side)
const purpleR = 0x5A;
const purpleG = 0x00;
const purpleB = 0xFF;
// Draw gradient by drawing many vertical lines (horizontal gradient)
const steps = GAME_WIDTH;
for (let i = 0; i <= steps; i++) {
const ratio = i / steps;
const r = Math.floor(blackR + (purpleR - blackR) * ratio);
const g = Math.floor(blackG + (purpleG - blackG) * ratio);
const b = Math.floor(blackB + (purpleB - blackB) * ratio);
bgGraphics.lineStyle(1, Phaser.Display.Color.GetColor(r, g, b), 1);
bgGraphics.moveTo(i, 0);
bgGraphics.lineTo(i, GAME_HEIGHT);
}
bgGraphics.strokePath();
}
getPlatformSpacing() {
// Start easy: 100-150 spacing (more platforms, easier to reach)
// Every 1000 score points, increase difficulty by reducing platform density
const difficultyLevel = Math.floor(this.score / 1000);
// Base spacing for score 0-999
let baseMin = 100;
let baseMax = 150;
// Increase spacing by 30-50 pixels per difficulty level (every 1000 points)
// This makes platforms further apart as you progress
const spacingIncrease = difficultyLevel * 40; // 40 pixels per level
const rangeIncrease = difficultyLevel * 20; // 20 pixels per level
return {
min: baseMin + spacingIncrease,
max: baseMax + spacingIncrease + rangeIncrease
};
}
getMaxHorizontalReach(barelyReachable) {
// Player can move horizontally at 300 px/s during jump
// Jump takes about 2.16 seconds (up and down to same height)
// Maximum horizontal distance ≈ 648px
if (barelyReachable) {
// Use maximum theoretical reach - barely possible, requires perfect timing
return 630; // Very close to theoretical max of 648px
} else {
// Use safe margin for easier jumps
return 550;
}
}
shouldMakeBarelyReachableJump() {
const difficultyLevel = Math.floor(this.score / 1000);
// Base chance starts at 10% (score 0-999)
// Increases by 5% per difficulty level (every 1000 points)
// Caps at 50% maximum frequency
const baseChance = 10;
const chanceIncrease = difficultyLevel * 5;
const totalChance = Math.min(baseChance + chanceIncrease, 50);
return Phaser.Math.Between(1, 100) <= totalChance;
}
getReachablePlatformPosition(previousPlatformX, previousPlatformWidth, newPlatformWidth, barelyReachable) {
barelyReachable = barelyReachable || false;
const maxReach = this.getMaxHorizontalReach(barelyReachable);
// Calculate edges of previous platform
const previousLeftEdge = previousPlatformX - previousPlatformWidth / 2;
const previousRightEdge = previousPlatformX + previousPlatformWidth / 2;
// Calculate reachable range for new platform center
// New platform must be positioned so its nearest edge is within maxReach of previous platform's nearest edge
const minNewCenter = Math.max(
50 + newPlatformWidth / 2, // Keep platform within game bounds
previousLeftEdge - maxReach + newPlatformWidth / 2 // Left edge reachable
);
const maxNewCenter = Math.min(
GAME_WIDTH - 250 + newPlatformWidth / 2, // Keep platform within game bounds
previousRightEdge + maxReach - newPlatformWidth / 2 // Right edge reachable
);
// If barely reachable, bias towards the edges of the reachable range for maximum difficulty
if (barelyReachable && maxNewCenter > minNewCenter) {
// 70% chance to place at the edge (most difficult), 30% chance anywhere in range
if (Phaser.Math.Between(1, 100) <= 70) {
// Choose left edge or right edge randomly
if (Phaser.Math.Between(1, 100) <= 50) {
return minNewCenter; // Left edge - barely reachable
} else {
return maxNewCenter; // Right edge - barely reachable
}
}
}
return Phaser.Math.Between(minNewCenter, maxNewCenter);
}
generateInitialPlatforms() {
// Create a full-width green (one-way) platform at the starting position
// This serves as the starting checkpoint
const startingPlatformY = this.startY + 50; // Platform below the spawn point
const startingPlatform = this.createPlatform(0, startingPlatformY, PlatformType.ONEWAY, GAME_WIDTH); // Full width, green
this.platforms.push(startingPlatform);
// Generate platforms starting from the starting position upward
// First platform should be close enough to reach from spawn (within jump height)
// Start with a platform directly above spawn, slightly offset horizontally
let currentY = this.startY - 80; // First platform close to spawn
// Create first platform - make sure it's reachable (centered or slightly offset)
const firstPlatformX = GAME_WIDTH / 2 + Phaser.Math.Between(-100, 100);
const firstPlatform = this.createPlatform(firstPlatformX, currentY);
this.platforms.push(firstPlatform);
// Generate platforms upward infinitely (no upper limit)
// Generate enough platforms to get started (about 100-150 platforms worth)
// Track previous platform X to ensure reachable horizontal distance
// Use easy spacing for initial generation (score is 0 at start)
const spacing = this.getPlatformSpacing();
let previousPlatformX = firstPlatformX;
for (let i = 0; i < 150; i++) { // Increased from 100 to 150 for easier start
currentY -= Phaser.Math.Between(spacing.min, spacing.max);
// Calculate new platform X position ensuring it's always reachable
// Use full width of game scene when possible, but guarantee reachability
let newPlatformX;
const newPlatformWidth = (PLATFORM_WIDTH_MIN + PLATFORM_WIDTH_MAX) / 2;
// Check if this should be a barely reachable jump (difficulty increases with score)
const barelyReachable = this.shouldMakeBarelyReachableJump();
// Occasionally (20% chance) try to place platforms at wider positions to use full width
// But still ensure they're reachable
if (Phaser.Math.Between(1, 100) <= 20) {
// Try to use full width, but ensure reachability
const maxReach = this.getMaxHorizontalReach(barelyReachable);
const previousLeftEdge = previousPlatformX - (PLATFORM_WIDTH_MIN + PLATFORM_WIDTH_MAX) / 4;
const previousRightEdge = previousPlatformX + (PLATFORM_WIDTH_MIN + PLATFORM_WIDTH_MAX) / 4;
// Allow anywhere in full width, but ensure edges are within reach
const minX = Math.max(50, previousLeftEdge - maxReach);
const maxX = Math.min(GAME_WIDTH - 250, previousRightEdge + maxReach);
// If we have enough range, place anywhere in full width
if (maxX - minX >= GAME_WIDTH * 0.5) {
newPlatformX = Phaser.Math.Between(50, GAME_WIDTH - 250);
// Verify it's actually reachable, if not, use reachable position
const nearestPrevEdge = Math.min(
Math.abs(newPlatformX - previousLeftEdge),
Math.abs(newPlatformX - previousRightEdge)
);
if (nearestPrevEdge > maxReach) {
// Not reachable, use reachable position instead
newPlatformX = this.getReachablePlatformPosition(previousPlatformX, newPlatformWidth, newPlatformWidth, barelyReachable);
}
} else {
// Not enough range for full width, use reachable position
newPlatformX = Phaser.Math.Between(minX, maxX);
}
} else {
// Most of the time, ensure it's definitely reachable (but may be barely reachable if difficulty calls for it)
newPlatformX = this.getReachablePlatformPosition(previousPlatformX, newPlatformWidth, newPlatformWidth, barelyReachable);
}
const platform = this.createPlatform(newPlatformX, currentY);
this.platforms.push(platform);
previousPlatformX = platform.x + platform.width / 2; // Use actual center of platform for next calculation
}
}
createPlatform(x, y, type, width) {
// Use provided width or generate random width
const platformWidth = width || Phaser.Math.Between(PLATFORM_WIDTH_MIN, PLATFORM_WIDTH_MAX);
// Randomly choose platform type if not specified
// 60% normal (blue), 25% one-way (green), 15% disappearing (red)
let platformType;
if (type) {
platformType = type;
} else {
const rand = Phaser.Math.Between(1, 100);
if (rand <= 60) {
platformType = PlatformType.NORMAL;
} else if (rand <= 85) {
platformType = PlatformType.ONEWAY;
} else {
platformType = PlatformType.DISAPPEARING;
}
}
// Set color based on type
let color;
switch (platformType) {
case PlatformType.ONEWAY:
color = 0x00FF00; // Green
break;
case PlatformType.DISAPPEARING:
color = 0xFF0000; // Red
break;
default:
color = 0x0052FF; // Blue (normal)
}
// Create a graphics object for visual representation
const graphics = this.add.graphics();
graphics.fillStyle(color);
graphics.fillRect(0, 0, platformWidth, PLATFORM_HEIGHT);
graphics.setDepth(5);
graphics.x = x;
graphics.y = y;
// Create an invisible sprite for physics (centered at platform position)
const platformSprite = this.add.rectangle(x + platformWidth / 2, y + PLATFORM_HEIGHT / 2, platformWidth, PLATFORM_HEIGHT, color);
const platformBody = this.physics.add.existing(platformSprite, true); // true = static
// Add to appropriate group based on type
if (platformType === PlatformType.NORMAL || platformType === PlatformType.DISAPPEARING) {
this.platformsGroup.add(platformBody);
} else if (platformType === PlatformType.ONEWAY) {
this.oneWayPlatformsGroup.add(platformBody);
}
// Hide the physics sprite (we use graphics for visual)
platformSprite.setVisible(false);
return new Platform(graphics, x, y, platformWidth, platformType, platformBody, false);
}
setupCamera() {
// Camera setup for Doodle Jump style:
// - Camera follows player upward only (infinite upward)
// - Camera does NOT scroll down if player falls
// - Player dies when they fall below camera viewport bottom
// - Player should stay near BOTTOM of screen (not top)
// Set camera bounds: allow infinite upward scrolling, bounded on sides
this.cameras.main.setBounds(0, -Infinity, GAME_WIDTH, Infinity);
// Don't set initial scroll here - let update() handle it on first frame
// Start with infinity so first update will properly initialize
this.highestCameraY = Infinity;
// Remove deadzone so camera follows smoothly
this.cameras.main.setDeadzone(0, 0);
}
setupKeyboardControls() {
try {
this.cursors = this.input.keyboard.createCursorKeys();
this.wasdKeys = this.input.keyboard.addKeys('W,S,A,D');
this.spaceKey = this.input.keyboard.addKey('SPACE');
} catch (err) {
console.log('Error setting up keyboard controls:', err);
}
}
setupTouchControls() {
const buttonSize = 80;
const spacing = buttonSize;
const centerX = GAME_WIDTH / 2;
const centerY = GAME_HEIGHT - 150;
// Left button
this.leftButton = this.add.text(centerX - spacing - buttonSize / 2, centerY, '◄', {
fontSize: '60px',
fontFamily: 'Arial',
color: '#ffffff',
backgroundColor: '#4A90E2',
padding: { x: 20, y: 15 },
align: 'center'
});
this.leftButton.setOrigin(0.5);
this.leftButton.setInteractive({ useHandCursor: true });
this.leftButton.setDepth(100);
this.leftButton.setScrollFactor(0);
this.leftButton.on('pointerdown', () => {
if (!this.gameStarted) {
this.gameStarted = true;
this.handleFirstInteraction();
}
this.leftButtonPressed = true;
if (this.player) {
this.player.setFlipX(true);
}
});
this.leftButton.on('pointerup', () => {
this.leftButtonPressed = false;
});
this.leftButton.on('pointerout', () => {
this.leftButtonPressed = false;
});
// Right button
this.rightButton = this.add.text(centerX + spacing + buttonSize / 2, centerY, '►', {
fontSize: '60px',
fontFamily: 'Arial',
color: '#ffffff',
backgroundColor: '#4A90E2',
padding: { x: 20, y: 15 },
align: 'center'
});
this.rightButton.setOrigin(0.5);
this.rightButton.setInteractive({ useHandCursor: true });
this.rightButton.setDepth(100);
this.rightButton.setScrollFactor(0);
this.rightButton.on('pointerdown', () => {
if (!this.gameStarted) {
this.gameStarted = true;
this.handleFirstInteraction();
}
this.rightButtonPressed = true;
if (this.player) {
this.player.setFlipX(false);
}
});
this.rightButton.on('pointerup', () => {
this.rightButtonPressed = false;
});
this.rightButton.on('pointerout', () => {
this.rightButtonPressed = false;
});
// Hover effects
this.leftButton.on('pointerover', () => {
this.leftButton.setBackgroundColor('#6BA3E3');
});
this.leftButton.on('pointerout', () => {
this.leftButton.setBackgroundColor('#4A90E2');
});
this.rightButton.on('pointerover', () => {
this.rightButton.setBackgroundColor('#6BA3E3');
});
this.rightButton.on('pointerout', () => {
this.rightButton.setBackgroundColor('#4A90E2');
});
}
createMusicButton() {
const musicBtn = this.add.text(GAME_WIDTH - 150, 50, '🔇', {
fontSize: '40px',
fontFamily: 'Arial',
backgroundColor: '#333',
padding: { x: 15, y: 10 }
});
musicBtn.setOrigin(0, 0);
musicBtn.setInteractive({ useHandCursor: true });
musicBtn.setDepth(100);
musicBtn.setScrollFactor(0);
musicBtn.on('pointerdown', () => {
if (!this.bgMusic) {
console.log('Music not available');
return;
}
if (!this.musicStarted) {
try {
const playPromise = this.bgMusic.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(err => {
console.log('Audio blocked by browser:', err);
});
}
this.musicStarted = true;
musicBtn.setText('🔊');
} catch (err) {
console.log('Error playing music:', err);
}
} else {
try {
if (this.bgMusic.isPlaying) {
this.bgMusic.pause();
musicBtn.setText('🔇');
} else {
this.bgMusic.resume();
musicBtn.setText('🔊');
}
} catch (err) {
console.log('Error controlling music:', err);
}
}
});
}
handleFirstInteraction() {
this.startMusicIfNeeded();
}
startMusicIfNeeded() {
if (!this.musicStarted && this.bgMusic) {
try {
const playPromise = this.bgMusic.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(err => {
console.log('Audio blocked by browser:', err);
});
}
this.musicStarted = true;
} catch (err) {
console.log('Error playing music:', err);
}
}
}
jump() {
// Allow jumping if player is on ground (touching platform or at starting position)
if (this.player.body) {
const isOnGround = this.player.body.touching.down ||
(this.player.body.velocity.y >= -10 && this.player.body.velocity.y <= 10 &&
this.player.y >= this.startY - 100);
if (isOnGround && this.canJump) {
this.player.setVelocityY(-650);
this.canJump = false; // Prevent multiple jumps
// Re-enable jump after a short delay
this.time.delayedCall(100, () => {
this.canJump = true;
});
}
}
}
handlePlatformCollision(player, platform) {
// Find which platform this is
const platformData = this.platforms.find(p => p.body === platform);
if (!platformData) return;
// Handle disappearing platforms - remove after first touch
if (platformData.type === PlatformType.DISAPPEARING && !platformData.hasBeenTouched) {
platformData.hasBeenTouched = true;
// Remove platform after a short delay for visual feedback
this.time.delayedCall(100, () => {
this.destroyPlatform(platformData);
});
return;
}
// Automatically bounce when landing on platform (falling)
if (this.player.body && this.player.body.velocity.y > 0) {
// Bounce upward - increase jump velocity to reach higher platforms
this.player.setVelocityY(-650); // Jump velocity
this.canJump = true; // Re-enable jumping after landing
}
}
destroyPlatform(platform) {
// Remove from platforms array
const index = this.platforms.indexOf(platform);
if (index > -1) {
this.platforms.splice(index, 1);
}
// Destroy graphics
if (platform.graphics) {
platform.graphics.destroy();
}
// Remove from physics group and destroy body
if (platform.body) {
if (platform.type === PlatformType.NORMAL || platform.type === PlatformType.DISAPPEARING) {
this.platformsGroup.remove(platform.body);
} else if (platform.type === PlatformType.ONEWAY) {
this.oneWayPlatformsGroup.remove(platform.body);
}
platform.body.destroy();
}
}
handleOneWayCollision(player, platform) {
// One-way platforms: can jump through from below, but bounce when landing from above
// The process callback already ensures we only get here when player is falling
if (this.player.body && this.player.body.velocity.y > 0) {
// Player is falling onto platform - bounce
this.player.setVelocityY(-650);
this.canJump = true;
}
}
update() {
if (this.isGameOver) {
return;
}
const moveSpeed = 300;
if (this.cursors && this.cursors.left.isDown || this.wasdKeys && this.wasdKeys.A.isDown || this.leftButtonPressed) {
this.player.setVelocityX(-moveSpeed);
if (this.player) {
this.player.setFlipX(true);
}
if (!this.gameStarted) {
this.gameStarted = true;
this.handleFirstInteraction();
}
} else if (this.cursors && this.cursors.right.isDown || this.wasdKeys && this.wasdKeys.D.isDown || this.rightButtonPressed) {
this.player.setVelocityX(moveSpeed);
if (this.player) {
this.player.setFlipX(false);
}
if (!this.gameStarted) {
this.gameStarted = true;
this.handleFirstInteraction();
}
} else {
if (this.player.body) {
this.player.setVelocityX(this.player.body.velocity.x * 0.9);
}
}
// Handle jump input (W, Up arrow, or Space) - manual jump still works
if (this.cursors && this.cursors.up.isDown ||
this.wasdKeys && this.wasdKeys.W.isDown ||
this.spaceKey && this.spaceKey.isDown) {
if (!this.gameStarted) {
this.gameStarted = true;
this.handleFirstInteraction();
}
this.jump();
}
// AUTO-JUMP: Always jump when on ground (Doodle Jump style - continuous bouncing)
// This makes it feel like W is always held down
if (this.player.body && this.player.body.touching.down && this.canJump) {
if (!this.gameStarted) {
this.gameStarted = true;
this.handleFirstInteraction();
}
this.jump();
}
// Update score based on highest Y reached (inverted since Y increases downward)
const currentY = this.player.y;
if (currentY < this.highestY || this.highestY === 0) {
this.highestY = currentY;
const newScore = Math.floor((this.startY - this.highestY) / 10);
this.score = newScore;
this.scoreText.setText('Score: ' + this.score);
// Check if we've crossed a checkpoint milestone (every 1000 score points)
const checkpointMilestone = Math.floor(this.score / 1000) * 1000;
if (checkpointMilestone > this.lastCheckpointScore && checkpointMilestone > 0) {
this.lastCheckpointScore = checkpointMilestone;
// Spawn a full-width green checkpoint platform
// Calculate Y position based on score: score = (startY - highestY) / 10
// So for checkpoint at score N, Y = startY - (N * 10)
const checkpointY = this.startY - (checkpointMilestone * 10);
const checkpointPlatform = this.createPlatform(0, checkpointY, PlatformType.ONEWAY, GAME_WIDTH);
this.platforms.push(checkpointPlatform);
}
}
// FIXED CAMERA TRACKING - Doodle Jump style
const viewportHeight = this.cameras.main.height;
const offsetFromBottom = 500; // Keep player 500px from bottom
// Calculate where camera should be to keep player at desired position
// If player is at Y=1000, and we want them 500px from bottom of 800px viewport:
// Camera scrollY should be 1000 - (800 - 500) = 1000 - 300 = 700
const desiredCameraY = this.player.y - (viewportHeight - offsetFromBottom);
// Initialize on first frame
if (this.highestCameraY === Infinity) {
this.highestCameraY = desiredCameraY;
this.cameras.main.scrollY = desiredCameraY;
}
// Only move camera UP (lower Y values), never down
// Player climbing = player.y decreases = desiredCameraY decreases
if (desiredCameraY < this.highestCameraY) {
this.highestCameraY = desiredCameraY;
this.cameras.main.scrollY = desiredCameraY;
}
// Generate new platforms as player ascends
this.generatePlatformsAsNeeded();
// Check for game over - player falls below camera viewport (outside visible area)
const cameraBottom = this.cameras.main.scrollY + viewportHeight;
if (this.player.y > cameraBottom) {
this.gameOver();
}
}
generatePlatformsAsNeeded() {
// Find the highest platform Y position (lowest Y value since Y increases downward)
let highestPlatformY = Infinity; // Start with infinity to find the actual highest
for (const platform of this.platforms) {
if (platform.y < highestPlatformY) {
highestPlatformY = platform.y;
}
}
// If no platforms exist, start from a reasonable position
if (highestPlatformY === Infinity) {
highestPlatformY = this.startY - 80;
}
// Generate new platforms above the highest one (infinite upward)
const cameraTop = this.cameras.main.scrollY;
// Find the X position of the highest platform to ensure reachable horizontal distance
let previousPlatformX = GAME_WIDTH / 2; // Default to center if no platform found
for (const platform of this.platforms) {
if (platform.y === highestPlatformY) {
previousPlatformX = platform.x + platform.width / 2; // Use center of platform
break;
}
}
// Keep generating platforms as long as we need them (above camera)
// Use dynamic spacing based on current score (progressive difficulty)
const spacing = this.getPlatformSpacing();
while (highestPlatformY > cameraTop - 500) { // Generate ahead of camera
// Ensure spacing is within jump reach, but adjust based on difficulty
const platformSpacing = Phaser.Math.Between(spacing.min, spacing.max);
const newY = highestPlatformY - platformSpacing;
// Calculate new platform X position ensuring it's always reachable
// Use full width of game scene when possible, but guarantee reachability
let newPlatformX;
const newPlatformWidth = (PLATFORM_WIDTH_MIN + PLATFORM_WIDTH_MAX) / 2; // Estimate for calculation
// Find the actual platform at highestPlatformY to get its real width
let previousPlatformWidth = newPlatformWidth;
for (const platform of this.platforms) {
if (platform.y === highestPlatformY) {
previousPlatformWidth = platform.width;
break;
}
}
// Check if this should be a barely reachable jump (difficulty increases with score)
const barelyReachable = this.shouldMakeBarelyReachableJump();
// Occasionally (20% chance) try to place platforms at wider positions to use full width
// But still ensure they're reachable
if (Phaser.Math.Between(1, 100) <= 20) {
// Try to use full width, but ensure reachability
const maxReach = this.getMaxHorizontalReach(barelyReachable);
const previousLeftEdge = previousPlatformX - previousPlatformWidth / 2;
const previousRightEdge = previousPlatformX + previousPlatformWidth / 2;
// Allow anywhere in full width, but ensure edges are within reach
const minX = Math.max(50, previousLeftEdge - maxReach);
const maxX = Math.min(GAME_WIDTH - 250, previousRightEdge + maxReach);
// If we have enough range, place anywhere in full width
if (maxX - minX >= GAME_WIDTH * 0.5) {
newPlatformX = Phaser.Math.Between(50, GAME_WIDTH - 250);
// Verify it's actually reachable, if not, use reachable position
const nearestPrevEdge = Math.min(
Math.abs(newPlatformX - previousLeftEdge),
Math.abs(newPlatformX - previousRightEdge)
);
if (nearestPrevEdge > maxReach) {
// Not reachable, use reachable position instead
newPlatformX = this.getReachablePlatformPosition(previousPlatformX, previousPlatformWidth, newPlatformWidth, barelyReachable);
}
} else {
// Not enough range for full width, use reachable position
newPlatformX = Phaser.Math.Between(minX, maxX);
}
} else {
// Most of the time, ensure it's definitely reachable (but may be barely reachable if difficulty calls for it)
newPlatformX = this.getReachablePlatformPosition(previousPlatformX, previousPlatformWidth, newPlatformWidth, barelyReachable);
}
const platform = this.createPlatform(newPlatformX, newY);
this.platforms.push(platform);
highestPlatformY = newY;
previousPlatformX = platform.x + platform.width / 2; // Use actual center of platform for next calculation
}
const cameraBottom = this.cameras.main.scrollY + this.cameras.main.height;
for (let i = this.platforms.length - 1; i >= 0; i--) {
const platform = this.platforms[i];
if (platform.y > cameraBottom + 500) {
platform.graphics.destroy();
this.platforms.splice(i, 1);
}
}
}
gameOver() {
this.isGameOver = true;
if (this.player.body) {
this.player.setVelocity(0, 0);
}
const gameOverText = this.add.text(this.cameras.main.centerX, this.cameras.main.centerY - 100, 'GAME OVER', {
fontSize: '100px',
fontFamily: 'Arial',
color: '#ff0000',
stroke: '#000000',
strokeThickness: 12
});
gameOverText.setOrigin(0.5);
gameOverText.setDepth(200);
gameOverText.setScrollFactor(0);
const restartButton = this.add.text(this.cameras.main.centerX, this.cameras.main.centerY + 50, 'Restart', {
fontSize: '60px',
fontFamily: 'Arial',
color: '#ffffff',
backgroundColor: '#4A90E2',
padding: { x: 40, y: 20 },
stroke: '#000000',
strokeThickness: 6
});
restartButton.setOrigin(0.5);
restartButton.setDepth(200);
restartButton.setScrollFactor(0);
restartButton.setInteractive({ useHandCursor: true });
restartButton.on('pointerdown', () => {
this.restartGame();
});
restartButton.on('pointerover', () => {
restartButton.setBackgroundColor('#6BA3E3');
});
restartButton.on('pointerout', () => {
restartButton.setBackgroundColor('#4A90E2');
});
}
restartGame() {
if (this.bgMusic && this.bgMusic.isPlaying) {
this.bgMusic.stop();
}
this.scene.restart();
}
}
const config = {
type: Phaser.AUTO,
width: GAME_WIDTH,
height: GAME_HEIGHT,
parent: 'tower-game-container',
backgroundColor: '#000000', // Black (gradient will be drawn in scene)
physics: {
default: 'arcade',
arcade: {
gravity: { x: 0, y: 600 }
}
},
scene: TowerScene,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
width: GAME_WIDTH,
height: GAME_HEIGHT
},
render: {
pixelArt: true,
antialias: false,
roundPixels: true
}
};
const game = new Phaser.Game(config);
// Handle window resize
window.addEventListener('resize', () => {
game.scale.refresh();
});
}
</script>
</body>
</html>OpenSea JSON Metadata
For your game to be displayed on OpenSea and other NFT marketplaces, it needs to be referenced in NFT metadata as JSON. The metadata follows the OpenSea metadata standard and includes your game's HTML file as the animation_url.
Metadata Structure
Here's an example of the JSON metadata format:
{
"name": "Snake",
"description": "Classic snake game with Gotchi sprites",
"image": "http://localhost:5173/api/mini-game/1.png",
"animation_url": "http://localhost:5173/api/mini-game/1.html",
"attributes": [
{
"trait_type": "Game Type",
"value": "Snake"
}
]
}Field Descriptions
name: The name of your game (displayed on OpenSea)description: A brief description of your gameimage: URL to a preview/thumbnail image (PNG, JPG, or GIF). This is the static image shown when the game isn't playing. Recommended size: 700x700px or largeranimation_url: URL to your standalone HTML game file. This is where OpenSea will embed your game in an iframe. This is the key field that makes your game playable!attributes(optional): Array of trait objects that can be used for filtering and display. Each attribute has:trait_type: The category name (e.g., "Game Type", "Difficulty")value: The value for that trait (e.g., "Snake", "Easy")
How It Works
- NFT Contract URI: When an NFT is minted, its
tokenURIpoints to a JSON endpoint (e.g.,/api/mini-game/1.json) - OpenSea Fetches Metadata: OpenSea requests the JSON from your endpoint
- Rendering: OpenSea displays:
- The
imageas a thumbnail/preview - The
animation_url(your HTML game) in an iframe when users click to view the NFT
- The
- Game Plays: Your standalone HTML file loads and runs in OpenSea's iframe, allowing users to play directly on the marketplace
Requirements for Metadata Endpoints
- CORS Headers: The JSON endpoint must include
Access-Control-Allow-Origin: *header - Content-Type: Should return
application/json - HTTPS in Production: OpenSea requires HTTPS for production URLs
- Valid URLs: Both
imageandanimation_urlmust be publicly accessible URLs
Example API Endpoint
Your metadata endpoint should return JSON like this:
// GET /api/mini-game/1.json
// Returns:
{
"name": "Snake",
"description": "Classic snake game with Gotchi sprites",
"image": "https://gotchi.lol/api/mini-game/1.png",
"animation_url": "https://gotchi.lol/api/mini-game/1.html",
"attributes": [
{
"trait_type": "Game Type",
"value": "Snake"
}
]
}
// Headers should include:
// Content-Type: application/json
// Access-Control-Allow-Origin: *Testing Your Metadata
You can test your metadata endpoint by:
- Opening the JSON URL directly in your browser
- Verifying all URLs in the JSON are accessible
- Using OpenSea's testnet to preview how your NFT will appear
- Checking that the
animation_urlloads and plays correctly in an iframe
Examples
See real examples of games embedded as NFT metadata on OpenSea:
- Gotchi Minigames Collection - A collection of playable mini games on OpenSea, including Snake and Tower games. Click on any NFT to see the games play directly in the OpenSea interface.
These examples demonstrate:
- How games appear in NFT listings
- How the
animation_urlembeds games in OpenSea's iframe - How preview images (
imagefield) are displayed as thumbnails - How attributes are used for filtering and organization
- Real-world implementation of the metadata standard
Visit the collection to see how your game will look once deployed as NFT metadata!
Key Implementation Details
Asset Loading with CORS
All assets must be served with CORS headers. Use a CDN or server that enables CORS:
// ✅ CORRECT - Asset served with CORS headers
this.load.image('sprite', 'https://your-cors-enabled-domain.com/sprite.png');
// Set CORS for all loads
this.load.setCORS('anonymous');
// ❌ WRONG - Direct path may not have CORS headers
this.load.image('sprite', '/sprites/sprite.png');Audio Handling
Audio requires user interaction before playing (browser autoplay policies). Always implement a music button:
createMusicButton() {
const musicBtn = this.add.text(GAME_WIDTH - 150, 50, '🔇', {
fontSize: '40px',
backgroundColor: '#333',
padding: { x: 15, y: 10 }
});
musicBtn.setInteractive({ useHandCursor: true });
musicBtn.setDepth(100);
musicBtn.setScrollFactor(0); // Keep UI fixed
musicBtn.on('pointerdown', () => {
if (!this.musicStarted) {
const playPromise = this.bgMusic.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(err => {
console.log('Audio blocked by browser:', err);
});
}
this.musicStarted = true;
musicBtn.setText('🔊');
} else {
if (this.bgMusic.isPlaying) {
this.bgMusic.pause();
musicBtn.setText('🔇');
} else {
this.bgMusic.resume();
musicBtn.setText('🔊');
}
}
});
}Touch Controls
Always include touch controls for mobile. Use setScrollFactor(0) to keep UI elements fixed on screen:
setupTouchControls() {
const button = this.add.text(x, y, 'Button', {
fontSize: '60px',
color: '#ffffff',
backgroundColor: '#4A90E2',
padding: { x: 20, y: 15 }
});
button.setOrigin(0.5);
button.setInteractive({ useHandCursor: true });
button.setDepth(100);
button.setScrollFactor(0); // Keep UI fixed on screen
button.on('pointerdown', () => {
// Handle input
});
// Hover effects
button.on('pointerover', () => {
button.setBackgroundColor('#6BA3E3');
});
button.on('pointerout', () => {
button.setBackgroundColor('#4A90E2');
});
}Error Handling
Always check if assets loaded successfully and handle errors gracefully:
create() {
// Check if assets loaded
if (!this.textures.exists('sprite')) {
console.error('Sprite texture not found!');
this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2,
'Failed to load assets\nCheck console for details', {
fontSize: '50px',
color: '#ff0000',
backgroundColor: '#000000',
padding: { x: 20, y: 10 },
align: 'center'
}).setOrigin(0.5).setDepth(200);
return;
}
// Continue with game setup...
}Responsive Scaling
Use Phaser's scale manager to handle different viewport sizes:
const config = {
// ... other config ...
scale: {
mode: Phaser.Scale.FIT, // Fit to screen
autoCenter: Phaser.Scale.CENTER_BOTH,
width: GAME_WIDTH,
height: GAME_HEIGHT
}
};Best Practices
Performance
- Use object pooling for frequently created/destroyed objects
- Clean up unused objects and timers
- Limit particle effects and visual complexity
- Use texture atlases instead of individual images
- Keep file size reasonable (under 500KB if possible)
Mobile Compatibility
- Always include touch controls - don't rely on keyboard only
- Make buttons large enough for touch (minimum 60x60px)
- Test on actual mobile devices
- Use
Phaser.Scale.FITfor responsive scaling - Use
setScrollFactor(0)for UI elements that should stay fixed
OpenSea Compatibility
- Test in OpenSea's iframe environment if possible
- Handle keyboard input failures gracefully (it may not be available)
- Don't rely on localStorage, cookies, or other browser storage
- Ensure all assets have CORS headers
- Use CDN fallbacks for Phaser.js loading
- Include proper meta tags for previews
Code Quality
- Comment complex logic
- Use consistent naming conventions
- Handle edge cases (missing assets, input failures, etc.)
- Test with slow network connections
- Test with different viewport sizes
Contributing
We welcome game contributions! To contribute your game:
- Create a standalone HTML file following the requirements above
- Test your game thoroughly:
- ✅ Game loads without errors
- ✅ All assets load correctly (with CORS headers)
- ✅ Keyboard controls work (if implemented)
- ✅ Touch controls work on mobile
- ✅ Audio button works (if music is included)
- ✅ Game scales properly on different screen sizes
- ✅ No console errors
- ✅ Works in an iframe (test by opening the HTML file in an iframe)
- Submit your HTML file along with:
- Game name and description
- Any assets used (or links to CORS-enabled assets)
- Instructions for testing
What We Need
Just your HTML file! That's it. We'll handle the rest of the integration. Make sure your HTML file is:
- Complete and standalone (everything in one file)
- Tested and working
- Following all the requirements and best practices above
Game Ideas
Looking for inspiration? Here are some game ideas that work well in NFT metadata:
- Classic arcade games (Pac-Man, Space Invaders, Breakout)
- Puzzle games (Tetris, Match-3, Sokoban)
- Endless runners (side-scrolling or vertical)
- Simple action games (shooting galleries, platformers)
- Idle/clicker games
- Snake games
- Platform jumpers
Resources
Questions?
If you have questions or need help, please reach out to Zaunzi on the Aavegotchi Discord.
Happy game making! 🎮