Skip to content

Vue 3 Integration - Ready to Use

Vue 3 is Already Integrated!

Everything is configured and works out of the box.


What's Included

1. Dependencies

package.json:

json
{
  "dependencies": {
    "vue": "^3.5.13"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.2.1"
  }
}

2. Vite Configuration

vite.config.ts:

typescript
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
  plugins: [vue()], //  Vue plugin
  resolve: {
    alias: {
      "@core": resolve(__dirname, "./src/core"),
      "@game": resolve(__dirname, "./src/game"),
    },
  },
});

3. TypeScript Support

src/shims-vue.d.ts:

typescript
declare module "*.vue" {
  import type { DefineComponent } from "vue";
  const component: DefineComponent<{}, {}, any>;
  export default component;
}

4. HTML Container

index.html:

html
<style>
  #ui-container {
    position: fixed !important;
    top: 0 !important;
    left: 0 !important;
    width: 100vw !important;
    height: 100vh !important;
    z-index: 1000 !important;
    pointer-events: none !important;
  }

  #ui-container > * {
    pointer-events: auto !important;
  }
</style>
<body>
  <div id="game-container"></div>
  <!-- #ui-container is created automatically by UIManager -->
</body>

5. Core Systems

  • src/core/ui/UIManager.ts - UI layer management
  • src/core/ui/VueUIBridge.ts - Vue 3 integration
  • Event Bus - built-in
  • Wrapper Component - automatic

How to Use

Step 1: Create Vue Component

src/game/ui/MyUI.vue:

vue
<template>
  <div class="my-ui">
    <h1>{{ title }}</h1>
    <p>{{ message }}</p>
    <button @click="handleAction">Click me</button>
  </div>
</template>

<script setup lang="ts">
import { getCurrentInstance } from "vue";

// Props (standard defineProps)
const props = defineProps<{
  title: string;
  message: string;
}>();

// Get globalEmit for events
const instance = getCurrentInstance();
const globalEmit = instance?.appContext.config.globalProperties.$emit;

// Emit events to Phaser
const handleAction = () => {
  console.log("Button clicked!");
  if (globalEmit) {
    globalEmit("action-clicked");
  }
};
</script>

<style scoped>
.my-ui {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  padding: 40px;
  background: rgba(0, 0, 0, 0.9);
  border: 2px solid white;
  color: white;
  font-family: monospace;
}

button {
  padding: 10px 20px;
  background: transparent;
  border: 2px solid white;
  color: white;
  cursor: pointer;
  transition: all 0.2s;
}

button:hover {
  background: white;
  color: black;
}
</style>

Step 2: Integrate into Phaser Scene

src/game/scenes/MyScene.ts:

typescript
import Phaser from "phaser";
import { UIManager } from "@core/ui/UIManager";
import { VueUIBridge } from "@core/ui/VueUIBridge";
import MyUI from "@game/ui/MyUI.vue"; //  TypeScript sees .vue!

export class MyScene extends Phaser.Scene {
  private uiManager!: UIManager;
  private vueBridge!: VueUIBridge;
  private score: number = 0;

  create() {
    // 1. Create UIManager
    this.uiManager = new UIManager(this, {
      containerId: "ui-container",
      enableLogging: process.env.NODE_ENV === "development",
    });

    // 2. Create VueUIBridge
    this.vueBridge = new VueUIBridge(this.uiManager);

    // 3. Mount Vue component
    this.vueBridge.mount("my-ui", MyUI, {
      props: {
        title: "My Game",
        message: "Welcome!",
      },
      events: {
        "action-clicked": () => {
          console.log("Phaser received action-clicked!");
          this.score += 10;
        },
      },
    });

    // 4. Show UI
    this.vueBridge.show("my-ui", true); // With animation
  }

  update() {
    // Update UI reactively!
    this.vueBridge.updateProps("my-ui", {
      message: `Score: ${this.score}`,
    });
  }

  shutdown() {
    // Automatic cleanup
    this.vueBridge.unmountAll();
    this.uiManager.shutdown();
  }
}

Done! Vue component works in Phaser game!


🔑 Key Concepts

1. Wrapper Component Pattern

Problem: defineProps doesn't react to external prop changes.

Solution in VueUIBridge:

typescript
const state = reactive(config.props || {});

const WrapperComponent = {
  setup() {
    return () => h(vueComponent, state);
    //         ↑
    // h() recreates VNode when state changes!
  },
};

const app = createApp(WrapperComponent);

Why it works:

  1. state is a reactive object
  2. updateProps() changes state
  3. h() is called again → new VNode
  4. Vue diff → re-render!
  5. 100% reactivity!

2. globalEmit for Events

In Vue component:

typescript
import { getCurrentInstance } from "vue";

const instance = getCurrentInstance();
const globalEmit = instance?.appContext.config.globalProperties.$emit;

const handleClick = () => {
  globalEmit("my-event", { data: "value" });
  // ↓
  // VueUIBridge → config.events["my-event"]() → Phaser!
};

IMPORTANT: Don't use defineEmits() - that's local emit!

typescript
//  DOESN'T WORK:
const emit = defineEmits<{ (e: "my-event"): void }>();
emit("my-event"); // Won't reach Phaser!

//  WORKS:
const globalEmit = instance?.appContext.config.globalProperties.$emit;
globalEmit("my-event"); // Reaches Phaser!

3. Event Bus

Bidirectional communication:

Vue → Phaser:

typescript
// In Vue
globalEmit("button-click", { action: "start" });

// In Phaser
this.vueBridge.mount("ui", Component, {
  events: {
    "button-click": (data) => {
      console.log(data.action); // "start"
    },
  },
});

Phaser → Vue:

typescript
// In Phaser
this.vueBridge.updateProps("ui", {
  score: 100,
  health: 80,
});

// In Vue
// props update automatically!
const props = defineProps<{
  score: number;
  health: number;
}>();
// props.score → 100

4. Lifecycle

typescript
create() {
  // Initialize
  this.uiManager = new UIManager(this);
  this.vueBridge = new VueUIBridge(this.uiManager);
  this.vueBridge.mount(...);
  this.vueBridge.show(...);
}

update() {
  // Reactive updates
  this.vueBridge.updateProps("my-ui", { ... });
}

shutdown() {
  // Automatic cleanup!
  this.vueBridge.unmountAll();
  this.uiManager.shutdown();
}

Proven Patterns

Pattern 1: Game HUD

Reactive HUD that updates every frame:

vue
<template>
  <div class="hud">
    <div class="timer">{{ formattedTime }}</div>
    <div class="score">{{ score }}</div>
    <div class="energy-bar" :style="{ width: energyPercent + '%' }"></div>
  </div>
</template>

<script setup lang="ts">
import { computed } from "vue";

const props = defineProps<{
  time: number;
  score: number;
  energy: number;
  maxEnergy: number;
}>();

const formattedTime = computed(() => {
  const m = Math.floor(props.time / 60);
  const s = props.time % 60;
  return `${m}:${s.toString().padStart(2, "0")}`;
});

const energyPercent = computed(() => {
  return (props.energy / props.maxEnergy) * 100;
});
</script>

Integration:

typescript
create() {
  this.vueBridge.mount("hud", GameHUD, {
    props: { time: 60, score: 0, energy: 100, maxEnergy: 100 }
  });
  this.vueBridge.show("hud");
}

update() {
  // Updates automatically!
  this.vueBridge.updateProps("hud", {
    time: this.gameTime,
    score: this.score,
    energy: this.energy,
  });
}

Pattern 2: Main Menu

Menu with buttons and events:

vue
<template>
  <div class="menu">
    <h1>{{ title }}</h1>
    <button @click="handleStart">START</button>
    <button @click="handleSettings">SETTINGS</button>
  </div>
</template>

<script setup lang="ts">
import { getCurrentInstance } from "vue";

defineProps<{ title: string }>();

const instance = getCurrentInstance();
const globalEmit = instance?.appContext.config.globalProperties.$emit;

const handleStart = () => globalEmit("start-game");
const handleSettings = () => globalEmit("open-settings");
</script>

Integration:

typescript
this.vueBridge.mount("main-menu", MainMenu, {
  props: { title: "My Game" },
  events: {
    "start-game": () => this.scene.start("GameScene"),
    "open-settings": () => this.scene.launch("SettingsScene"),
  },
});

Pattern 3: Theme Selector

Swiper for theme selection:

vue
<template>
  <div class="themes-swiper">
    <div
      v-for="theme in themes"
      :key="theme.id"
      class="theme-card"
      :class="{ 'is-active': theme.id === currentThemeId }"
      @click="handleThemeClick(theme.id)"
    >
      {{ theme.name }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { getCurrentInstance } from "vue";

defineProps<{
  currentThemeId: string;
  themes: Array<{ id: string; name: string }>;
}>();

const instance = getCurrentInstance();
const globalEmit = instance?.appContext.config.globalProperties.$emit;

const handleThemeClick = (themeId: string) => {
  globalEmit("change-theme", themeId);
};
</script>

<style scoped>
.themes-swiper {
  display: flex;
  gap: 20px;
  overflow-x: auto;
  justify-content: space-evenly;
}

.theme-card.is-active {
  border: 3px solid white; /* Active theme */
}
</style>

Integration:

typescript
this.vueBridge.mount("themes", ThemeSelector, {
  props: {
    currentThemeId: "minimal",
    themes: [
      { id: "minimal", name: "Minimal" },
      { id: "neon", name: "Neon" },
    ],
  },
  events: {
    "change-theme": (themeId: string) => {
      this.applyTheme(themeId);

      // Update UI for visual indication
      this.vueBridge.updateProps("themes", {
        currentThemeId: themeId,
      });
    },
  },
});

Technical Details

Event Flow

User clicks button in Vue

handleClick() calls globalEmit("event")

VueUIBridge.$emit

config.events["event"]() called

Phaser scene method executed

Props Update Flow

Phaser: updateProps({ score: 100 })

VueUIBridge: Object.assign(state, { score: 100 })

state = reactive object

WrapperComponent.setup() → h(Component, state)

h() sees state change → creates new VNode

Vue diff → re-render

UI updates!

Wrapper Component Pattern

Inside VueUIBridge:

typescript
const state = reactive(config.props || {});

const WrapperComponent = {
  setup() {
    return () => h(vueComponent, state);
    //         ↑
    // Render function - recreates VNode
    // on any state change!
  },
};

const app = createApp(WrapperComponent);

This guarantees:

  • 100% reactivity
  • Works with defineProps
  • No hacks needed
  • Standard Vue code

Approach Comparison

Doesn't work:

typescript
// Direct props passing
const app = createApp(MyComponent, state);
// defineProps DOESN'T see state changes!

Doesn't work:

typescript
// Spread operator
const app = createApp(MyComponent, { ...state });
// Object copy → no reactivity!

Works:

typescript
// Wrapper Component
const Wrapper = {
  setup() {
    return () => h(MyComponent, state);
  },
};
const app = createApp(Wrapper);
// h() recreates VNode → reactivity!

Best Practices

1. Always use getCurrentInstance()

typescript
import { getCurrentInstance } from "vue";

const instance = getCurrentInstance();
const globalEmit = instance?.appContext.config.globalProperties.$emit;

const handleEvent = () => {
  globalEmit("my-event", data); // 
};

2. Props only for display

typescript
//  Correct:
const props = defineProps<{
  score: number;
  health: number;
}>();
// Use only for display!

//  Wrong:
const emit = defineEmits(); // Won't reach Phaser!

3. updateProps for updates

typescript
// In Phaser update()
update() {
  this.vueBridge.updateProps("hud", {
    score: this.score,
    time: this.gameTime,
  });
}
// Vue re-renders automatically!

4. Use computed

vue
<script setup lang="ts">
import { computed } from "vue";

const props = defineProps<{
  time: number;
  energy: number;
  maxEnergy: number;
}>();

//  Computed for calculations
const formattedTime = computed(() => {
  const m = Math.floor(props.time / 60);
  const s = props.time % 60;
  return `${m}:${s.toString().padStart(2, "0")}`;
});

const energyPercent = computed(() => {
  return (props.energy / props.maxEnergy) * 100;
});
</script>

🐛 Troubleshooting

Problem: Component doesn't update

Cause: Props not reactive

Solution: Check that you're using VueUIBridge from @core/ui/, not creating your own! VueUIBridge uses Wrapper Component.


Problem: Events don't work

Cause: Using local emit from defineEmits

Solution: Use globalEmit:

typescript
const instance = getCurrentInstance();
const globalEmit = instance?.appContext.config.globalProperties.$emit;

const handleClick = () => {
  globalEmit("my-event"); // 
};

Problem: UI not centered

Cause: Container CSS incorrect

Solution:

vue
<style scoped>
.my-component {
  position: fixed; /* Not absolute! */
  top: 0;
  left: 0;
  width: 100vw; /* Not 100%! */
  height: 100vh; /* Not 100%! */
  display: flex;
  align-items: center;
  justify-content: center;
}
</style>

Problem: Clicks don't work

Cause: pointer-events: none on parent

Solution:

vue
<style scoped>
.my-component {
  pointer-events: auto; /* Enable events */
}

button {
  pointer-events: auto; /* And on buttons */
}
</style>

Checklist

Before using, make sure:

  • [x] vue installed in package.json
  • [x] @vitejs/plugin-vue in devDependencies
  • [x] vite.config.ts with vue() plugin
  • [x] src/shims-vue.d.ts exists
  • [x] #ui-container styles in index.html
  • [x] Import UIManager from @core/ui/UIManager
  • [x] Import VueUIBridge from @core/ui/VueUIBridge
  • [x] Use getCurrentInstance() for globalEmit
  • [x] Call shutdown() in scene

All set? Start creating UI!


📚 Additional Resources


Vue 3 is ready to use! Just import and create UI!

MIT Licensed