Recreating the Classic Duck Hunt Game with Vue 3 and PixiJS
This article walks through building a browser‑based Duck Hunt‑style shooting game using Vue 3 for the UI framework, PixiJS as the rendering engine, and supporting tools such as Vite, SCSS, mitt, and GSAP to handle asset loading, scene management, animations, hit detection, and responsive scaling.
Preface
The author revisits the childhood game "Duck Hunt" and recreates it with Vue 3 and PixiJS, replacing the original light‑gun control with mouse clicks to make the experience accessible on modern browsers.
Introduction
The tutorial explains how the game loads assets, draws the interface, creates animations, performs hit detection, and adapts to different screen sizes. Because most of the UI is drawn with PixiJS, the codebase is fairly large and detailed.
Game Rules
Each round consists of five turns, each turn spawning two ducks and providing three bullets.
Hitting a duck awards 500 points; hitting all ducks in a round grants a special bonus.
If bullets run out or time expires, the ducks fly away.
Scoring more than six ducks per round qualifies the player for the next round, up to three rounds total.
Game Flow
Click the start screen to enter the game.
A hunting‑dog cut‑scene plays at the beginning of each round.
After the cut‑scene, the player can shoot.
Each mouse click fires a bullet; hitting a duck adds points, while missed shots or depleted ammo cause the ducks to accelerate away.
After three rounds or failing to meet the advancement criteria, the game returns to the start screen and records the score.
Main Technologies
Vite – module bundling and build tasks.
Vue 3 – reactive component framework.
SCSS – styling and CSS animations for the loading screen.
mitt – lightweight publish/subscribe implementation.
PixiJS – core 2D rendering engine.
GSAP – animation handling.
Game Assets
The project uses the "Press Start 2P" pixel font, compressed audio (WAV → MP3), and sprite sheets split with ShoeBox. The author later describes an alternative method for handling these textures.
Start
Publish/Subscribe
import mitt from "mitt";
const bus = {};
const emitter = mitt();
bus.$on = emitter.on;
bus.$off = emitter.off;
bus.$emit = emitter.emit;
export default bus;Vue 3 no longer provides an off method, so mitt is used for event handling.
File Structure
<template>
<div>
<Loading v-if="progress<100" :progress="progress" />
<DuckGame />
</div>
</template>The DuckGame component is the main container for the game.
<template>
<div class="game" ref="canvas"></div>
</template> new Game({
width,
height,
el: canvas.value,
resolution: 1,
onProgress: n => {
Bus.$emit("changeProgress", n);
}
}).init();The game instance is created and the loading progress is emitted to a Vue component.
Scene Base Class
import { Container } from "pixi.js";
export default class Scene {
constructor(game) {
this.game = game;
this.stage = new Container();
this.stage.interactive = true;
this.stage.buttonMode = true;
this.stage.sortableChildren = true;
this.stage.zIndex = 1;
return this;
}
onStart() {}
init() {}
show() { this.stage.visible = true; }
hide() { this.stage.visible = false; }
update(delta) { if (!this.stage.visible) return; }
}Start Scene
import Scene from "./scene";
class StartScene extends Scene {
constructor(game) {
super(game);
this.topScore = null;
return this;
}
}
export default StartScene;Load Assets
export function getImageUrl(name, ext = "png") {
return new URL(`/src/assets/${name}.${ext}`, import.meta.url).href;
} const audioList = {
fire: getImageUrl("fire", "mp3"),
// ...more
};
const stage = getImageUrl("stage");
// ...more
export default {
stage,
...audioList,
// more
};Assets are loaded with PixiJS's Loader , and progress is reported back to Vue.
export default class Game {
// ...
init() {
this.loaderTextures().then(res => {
Object.entries(res).forEach(([key, value]) => setTextures(key, value.texture));
this.render();
});
}
loaderTextures() {
const { loader, onProgress } = this;
return new Promise((resolve, reject) => {
Object.entries(assets).forEach(([key, value]) => loader.add(key, value, () => {
onProgress(loader.progress);
}));
loader.load((loader, resources) => {
onProgress(loader.progress);
resolve(resources);
});
});
}
// ...
}Render UI
import { Text, Graphics, Container } from "pixi.js";
class StartScene extends Scene {
drawBg() {
const { width, height } = this.game;
const graphics = new Graphics();
graphics.beginFill(0x000000, 1);
graphics.drawRect(0, 0, width, height);
graphics.endFill();
this.stage.addChild(graphics);
}
drawTopScore(score = 0) {
const { width, height } = this.game;
this.topScore = new Text("top score = " + score, {
fontFamily: 'Press Start 2P',
fontSize: 24,
leading: 20,
fill: 0x66DB33,
align: 'center',
letterSpacing: 4
});
this.topScore.anchor.set(0.5, 0.5);
this.topScore.position.set(width / 2, height - 60);
this.stage.addChild(this.topScore);
}
}
export default StartScene;Game Animation
import { TimelineMax } from "gsap";
let btnAni = new TimelineMax().fromTo(this.btn, { alpha: 0 }, { alpha: 1, duration: .45, immediateRender: true, ease: "SteppedEase(1)" });
btnAni.repeat(-1);
btnAni.yoyo(true);Button flashing uses a stepped ease to mimic retro arcade style.
let dogSearchAni = new TimelineMax();
dogSearchAni
.from(dog, 0.16, { texture: getTextures("dog0"), ease: "SteppedEase(1)" })
.to(dog, 0.16, { texture: getTextures("dog1"), ease: "SteppedEase(1)" })
.to(dog, 0.16, { texture: getTextures("dog2"), ease: "SteppedEase(1)" })
.to(dog, 0.16, { texture: getTextures("dog3"), ease: "SteppedEase(1)" })
.to(dog, 0.2, { texture: getTextures("dog4"), ease: "SteppedEase(1)" });
dogSearchAni.repeat(-1);
dogSearchAni.play();Hit Detection
export default class Duck {
constructor({ dIndex = 0, x = 0, y = 0, speed = 3, direction = 1, stage, rect = [0, 0, 1200, 759] }) {
// ...
this.target = new Container();
// click handling
this.target.on("pointerdown", () => {
if (!this.isHit) this.isHit = true;
});
// bullet event
Bus.$on("sendBullet", ({ e, callback }) => {
if (this.isHit && !this.isDie) {
this.isDie = true;
this.hit();
this.duck_sound.play();
callback && callback(this);
}
});
// fly away event
Bus.$on("flyaway", () => {
this.isFlyaway = true;
});
return this;
}
// ...
async hit() {
const { sprite, score, target } = this;
this.normalAni.kill();
sprite.texture = getTextures("duck_9");
sprite.width = getTextures("duck_9").width;
sprite.height = getTextures("duck_9").height;
showScore({ parent: this.stage, score, x: target.x - (this.vx < 0 ? +sprite.width : 0), y: target.y });
await wait(.35);
this.die();
}
// ...
}Screen Adaptation
<script setup>
let width = 1200;
let height = 769;
const scale = `scale(${ window.innerHeight < window.innerWidth ? window.innerHeight / height : window.innerWidth / width })`;
</script>
<template>
<div class="game" ref="canvas"></div>
</template>
<style lang="scss" scoped>
.game {
transform: v-bind(scale);
cursor: none;
}
</style>Conclusion
PixiJS proves to be a powerful engine for this type of 2D game, though for more complex projects the author recommends Cocos Creator to reduce workload. The tutorial demonstrates how to recreate a nostalgic game while practicing modern frontend and graphics techniques.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.