Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when the user wants to build a 2D browser game - platformer, side-scroller, arcade, puzzle, endless runner - and share it at a live URL. Phaser 3 starter on Gipity with scenes, physics, input, sprites, and genre recipes.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-04 | ✗→✓ | ▲ Improved | 789% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 134% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 159% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 58% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 154% | 0% |
<!-- GENERATED from platform/docs/skills/2d-game.md by platform/scripts/sync-claude-plugin.ts - do not edit here. -->
> Gipity required. This skill needs the gipity CLI linked to a project. If gipity status errors or shows no project, run the setup flow in the gipity skill first (in Claude Code or Grok: /gipity:setup; in Codex or any other agent, follow the gipity skill's setup steps directly). > > This doc is shared across Gipity surfaces; where it names an agent tool, use the CLI equivalent: add → gipity add <name>, file_write/file_read/file_delete → edit files in the project directory directly (they auto-sync), project_deploy → gipity deploy dev, code_execute → gipity sandbox run. The live version of this doc: gipity skill read 2d-game.
2D Game is the Phaser-based game template on Gipity. All files are fully editable - no locked template layer. Uses Phaser 3.80.1 via CDN with arcade physics.
When to use this: When the user asks for a 2D game - platformer, side-scroller, arcade, puzzle, endless runner, top-down, shooter, or RPG. For simple games (wordle, quiz, card games), use web-simple. For 3D multiplayer apps, use 3d-engine for a blank template or 3d-world for a playable rocket-launcher starter.
STRONGLY RECOMMENDED: Begin every 2D game by adding the 2d-game template with add. It sets up Phaser 3, boot/game scenes, config, settings, plus generated icons, a PWA manifest, and a social share card. Only hand-roll files if the user explicitly tells you to skip the template.
add name=2d-game title="<Game Name>"Starting over in an existing project: If src/ already exists and the user wants a clean rebuild, call file_delete on src first, then run add normally. Or pass force=true to add to overwrite in one step - destructive, so confirm with the user first. Unrelated content (media, data, notes) is preserved either way.
Naming: Use the user's name verbatim if given. If they didn't specify, blend "Gip" or "Gipity" into the name (e.g. "Gipity Racer", "Gip Tac Toe") - be creative but don't force it.
This creates a playable game immediately - colored rectangle player, arcade physics, ground platform, and controls that work on desktop AND mobile out of the box (WASD/arrows + Space on keyboard; a floating virtual joystick, Jump button, and fullscreen toggle on touch devices). Then edit scenes/game.js and settings.js to build your game.
src/
index.html - Phaser CDN, game container, module entry
js/
config.js - Phaser.Game config, scene registration
controls.js - Touch controls overlay (virtual joystick, buttons, fullscreen)
settings.js - Tunable values (canvas, colors, physics, player, gameplay)
strings.js - User-facing display text
scenes/
boot.js - Preloader with progress bar
game.js - Main game scene (gameplay logic)
css/
styles.css - Page layout, canvas styling
images/ - generated app icons + share card (favicon-*.png/.ico,
apple-touch-icon.png, og-image.png); customize with
`gipity brand set --emoji <e>`, then redeployEvery scene has three key methods:
preload() - load assets (images, spritesheets, audio)create() - set up game objects, physics, inputupdate() - runs every frame, game logic hereIn boot.js preload():
jsthis.load.image('player', './images/player.png'); this.load.spritesheet('hero', './images/hero.png', { frameWidth: 32, frameHeight: 48 }); this.load.audio('jump', './audio/jump.mp3');
js// Sprite (from loaded image) this.player = this.physics.add.sprite(400, 300, 'player'); // Rectangle (no image needed) this.player = this.add.rectangle(400, 300, 32, 48, 0xf26522); this.physics.add.existing(this.player); // Static group (platforms) this.platforms = this.physics.add.staticGroup(); this.platforms.create(400, 568, 'ground');
js// Gravity is set in config.js via settings.physics.gravity body.setVelocityX(speed); body.setVelocityY(jumpForce); // negative = up body.setBounce(0.2); body.setCollideWorldBounds(true); // Collisions this.physics.add.collider(player, platforms); this.physics.add.overlap(player, coins, collectCoin, null, this);
js// Keyboard this.cursors = this.input.keyboard.createCursorKeys(); // up, down, left, right if (this.cursors.left.isDown) body.setVelocityX(-speed); // Specific keys this.spaceKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE); // Touch/pointer this.input.on('pointerdown', (pointer) => { ... });
jsthis.add.text(400, 30, 'Score: 0', { fontSize: '24px', color: '#ffffff', fontStyle: 'bold', }).setOrigin(0.5);
jsthis.cameras.main.startFollow(this.player, true, 0.1, 0.1); this.cameras.main.setBounds(0, 0, worldWidth, worldHeight);
These are the failure modes that most often turn the whole screen black. Read before writing a game scene.
Graphics.fillEllipse does NOT exist. Phaser 3 Graphics has fillCircle(x, y, r), fillRect, fillTriangle, fillRoundedRect, and beginPath + arc + fillPath - but no ellipse drawcall. For an oval, use fillCircle and set gfx.scaleX / gfx.scaleY, or draw it with beginPath/arc/fillPath.Graphics object. Graphics has no width/height for the body to size against, so the body ends up with {0,0} dimensions or drifts from the drawn shape. Use a Sprite (from a loaded image) or a Rectangle / Zone for the physics body, and draw a follower Graphics that tracks body.x/body.y each frame.this.physics.world.setBounds(0, 0, worldWidth, height) in create() - otherwise platforms and objects beyond the canvas get ignored by physics.this.segments = {} with string/number keys works; this.segments = [] with large indices becomes sparse and breaks Object.keys/values enumeration in subtle ways.this.physics.add.existing(obj) OR add obj to a staticGroup with group.add(obj) - not both. Double-creation silently misplaces the body relative to the visual.addKeys/createCursorKeys) as "captured": its window-level listener calls preventDefault() on them no matter where focus is, so a player literally cannot type W/A/S/D or Space into a high-score name field or chat box overlaid on the canvas. The template ships a shield in js/config.js (a document-level keydown/keyup listener that stopPropagation()s events targeting input/textarea/select/contenteditable, plus resetKeys() on focus) - keep it, and if you add your OWN window/document key listeners, start them with a guard: if (e.target.closest('input, textarea, select') || e.target.isContentEditable) return;. Test any name-entry UI by actually typing the movement keys into it.gipity page screenshot <url> from the CLI, or the browser agent tool's screenshot action in chat - don't trust "clean console" as proof. If gameplay only starts after a "play" click, drive it in the same shot so you capture the game and not the menu: gipity page screenshot <url> --action "document.getElementById('play').click()" (use your start button's selector).For anything non-trivial, don't write the whole game in one Write call. Work in small, verified steps:
gipity add 2d-game) and deploy - confirm the starter game renders.A 500-line rewrite of scenes/game.js is very hard to debug when something breaks - a single bad API call turns the whole screen black with no useful error. Small steps keep the failure surface tiny.
After every gipity deploy dev:
gipity page inspect <deploy-url> to surface console errors.gipity page screenshot <url> (CLI) or the browser agent tool's screenshot action (chat). To capture actual gameplay rather than the title screen, start the game in the same shot: gipity page screenshot <url> --action "document.getElementById('play').click()". A clean console is NOT sufficient proof for Canvas/WebGL apps - render failures are often silent.fillEllipse, or physics attached to a raw Graphics). Re-read the "Common Phaser 3 Pitfalls" section above before rewriting.A screenshot proves the game renders; it can't prove the ball bounces, the score increments, or the win screen ever fires. Drive the live game object instead. js/config.js exports the Phaser game, and index.html loads it as a module - so a dynamic import() from gipity page eval resolves out of the browser's module cache and hands you the running instance (relative specifiers resolve against the page URL). No second game boots.
Never ship a window.game debug hook to do this. That leaves instrumentation in your production bundle and costs an extra deploy.
bashgipity page eval "<deploy-url>" " const { game } = await import('./js/config.js'); const s = game.scene.getScene('Game'); s.startGame(); // call your own scene methods return JSON.stringify({ score: s.score, lives: s.lives, state: s.state }); "
For a longer driver (play a full round, force a win, assert the game-over overlay), put the script in a file and pass --file - no shell quoting, and the body may use await and return:
bashgipity page eval "<deploy-url>" --file ./tests/drive-game.js --json
The eval body has a ~20s in-page budget, so split a long sequence into one call per state you're verifying.
Never wait wall-clock time for physics/animation to play out - the headless browser can paint at ~15 fps, so setTimeout(2000) advances far less than 2s of game time and assertions report false negatives. config.js also exports advance(seconds): it pauses the browser loop and ticks Phaser's TimeStep by hand, so N simulated seconds (physics, timers, collisions) run in milliseconds, deterministically:
bashgipity page eval "<deploy-url>" " const { game, advance } = await import('./js/config.js'); const s = game.scene.getScene('Game'); s.startGame(); advance(5); // 5s of game time, instantly return JSON.stringify({ score: s.score, bricks: s.bricks.countActive() }); "
To capture a driven state visually, page screenshot --action "<js>" runs the same kind of script before the shot - same async body, same app-relative import(). If the action throws, you still get the image, plus a ⚠ --action failed: line telling you it shows the undriven page:
bashgipity page screenshot "<deploy-url>" -o win.png \ --action "const { game } = await import('./js/config.js'); game.scene.getScene('Game').winGame();"
Write throwaway driver scripts and screenshots outside the project directory (e.g. /tmp) - the project auto-syncs to Gipity, so scratch files land in the user's storage. If they must live in the project, add the path to .gipityignore.
jsthis.anims.create({ key: 'walk', frames: this.anims.generateFrameNumbers('hero', { start: 0, end: 3 }), frameRate: 10, repeat: -1, }); this.player.anims.play('walk', true);
src/js/scenes/myScene.js:jsexport class MyScene extends Phaser.Scene { constructor() { super('MyScene'); } create() { /* ... */ } update() { /* ... */ } }
config.js:jsimport { MyScene } from './scenes/myScene.js'; // Add to scene array: scene: [Boot, Game, MyScene],
this.scene.start('MyScene');this.cameras.main.startFollow(player)this.physics.world.setBounds(0, 0, 3200, 600)settings.physics.gravity = 0body.setVelocity(x, y) for diagonal movementspeed += dt * accelerationthis.physics.add.group() with body.setVelocity()this.time.addEvent({ delay: 1000, callback: spawnEnemy, loop: true })Math.round(x / tileSize) * tileSizeThe template is mobile-ready by default via js/controls.js - a DOM overlay above the canvas that renders only on touch devices (nothing shows on desktop). It gives the standard mobile-game layout: a floating virtual joystick on the left half (the pad appears wherever the thumb lands), round action buttons bottom-right, and a fullscreen toggle top-right (auto-hidden where the Fullscreen API is unavailable, e.g. iPhone Safari - there, "Add to Home Screen" runs fullscreen via the template's PWA meta tags). Multi-touch is tracked per pointer, so joystick + buttons work simultaneously.
jsimport { touch, initTouchControls } from '../controls.js'; // create(): declare the buttons your game needs (first = primary, biggest) initTouchControls({ buttons: [{ id: 'jump', label: 'Jump' }, { id: 'fire', label: 'Fire' }] }); // tap-only game? disable the joystick so it doesn't swallow lower-left taps: // initTouchControls({ joystick: false, buttons: [...] }); // update(): merge with keyboard - joystick is analog (-1..1) const keyX = (cursors.right.isDown ? 1 : 0) - (cursors.left.isDown ? 1 : 0); const moveX = keyX !== 0 ? keyX : touch.x; // touch.y for vertical body.setVelocityX(moveX * speed); if (touch.isDown('jump')) { /* held */ } if (touch.justPressed('fire')) { /* once per press */ }
Always keep desktop AND touch input working: keyboard = WASD + arrows (both), plus the matching touch buttons. touch.enabled tells you touch controls are active (e.g. to swap instruction text). Phaser still handles in-canvas taps via pointer events (this.input.on('pointerdown', ...)), with 3 active pointers configured.
For mobile-responsive canvas, the template uses Phaser.Scale.FIT + CENTER_BOTH by default - the game keeps its settings.canvas coordinate system and letterboxes to fit any screen or orientation. The page is hardened for games (no pinch-zoom, no overscroll, no text selection; dvh viewport).
Verify a deploy when it matters - the first deploy, structural changes (new pages, new frameworks, changed imports), or anything that might have broken. Skip it for trivial changes (copy tweaks, style values).
gipity deploy dev --inspect deploys and reports the live page in one step: console errors, failed resources, timing, layout overflow. A clean console is necessary but NOT sufficient for Canvas/WebGL - also capture gipity page screenshot <url> and look at it, because render failures are silent. A blank page, black canvas, or wrong-looking UI with a clean console is a real failure, not a pass.
Full loop - reading function logs, calling a function directly, driving the page: the app-debugging skill.
Other measured skills in the registry, with their headline benchmark lift.