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

  1. Overview
  2. Requirements for OpenSea Compatibility
  3. Complete HTML Template
  4. OpenSea JSON Metadata
  5. Examples
  6. Key Implementation Details
  7. Best Practices
  8. Contributing

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 game
  • image: 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 larger
  • animation_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

  1. NFT Contract URI: When an NFT is minted, its tokenURI points to a JSON endpoint (e.g., /api/mini-game/1.json)
  2. OpenSea Fetches Metadata: OpenSea requests the JSON from your endpoint
  3. Rendering: OpenSea displays:
    • The image as a thumbnail/preview
    • The animation_url (your HTML game) in an iframe when users click to view the NFT
  4. 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 image and animation_url must 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_url loads 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_url embeds games in OpenSea's iframe
  • How preview images (image field) 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.FIT for 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:

  1. Create a standalone HTML file following the requirements above
  2. 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)
  3. 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! 🎮