diff --git a/src/features/particles/Particles.vue b/src/features/particles/Particles.vue
index 57feb7e..afe40be 100644
--- a/src/features/particles/Particles.vue
+++ b/src/features/particles/Particles.vue
@@ -1,11 +1,19 @@
+onMounted,
 <template>
     <Particles
-        id="particles"
+        :id="id"
+        :class="{
+            'not-fullscreen': !fullscreen
+        }"
+        :style="{
+            zIndex
+        }"
         ref="particles"
         :particlesInit="particlesInit"
+        :particlesLoaded="particlesLoaded"
         :options="{
             fpsLimit: 60,
-            fullScreen: { zIndex: 1 },
+            fullScreen: { enable: fullscreen, zIndex },
             particles: {
                 number: {
                     value: 0
@@ -15,40 +23,105 @@
                 autoPlay: false
             }
         }"
+        v-bind="$attrs"
     />
+    <div ref="resizeListener" class="resize-listener" />
 </template>
 
 <script lang="tsx">
 import { loadFull } from "tsparticles";
-import { Engine } from "tsparticles-engine";
+import { Engine, Container } from "tsparticles-engine";
 import { Emitters } from "tsparticles-plugin-emitters/Emitters";
-import { defineComponent, nextTick, ref } from "vue";
+import { EmitterContainer } from "tsparticles-plugin-emitters/EmitterContainer";
+import { defineComponent, inject, nextTick, onMounted, PropType, ref } from "vue";
 import { ParticlesComponent } from "particles.vue3";
+import { NodesInjectionKey } from "game/layers";
 
 // TODO get typing support on the Particles component
 export default defineComponent({
-    emits: ["init"],
+    props: {
+        zIndex: {
+            type: Number,
+            required: true
+        },
+        fullscreen: {
+            type: Boolean,
+            required: true
+        },
+        onInit: {
+            type: Function as PropType<(container: EmitterContainer & Container) => void>,
+            required: true
+        },
+        id: {
+            type: String,
+            required: true
+        },
+        onContainerResized: Function as PropType<(rect: DOMRect) => void>
+    },
     components: { Particles: ParticlesComponent },
-    setup(props, { emit }) {
+    setup(props) {
         const particles = ref<null | { particles: { container: Emitters } }>(null);
 
         async function particlesInit(engine: Engine) {
             await loadFull(engine);
-            finishInit(engine);
         }
 
-        function finishInit(engine: Engine) {
-            if (engine.domArray.length) {
-                emit("init", engine.domItem(0));
-            } else {
-                nextTick(() => finishInit(engine));
+        function particlesLoaded(container: EmitterContainer & Container) {
+            props.onInit(container);
+        }
+
+        const resizeObserver = new ResizeObserver(updateBounds);
+
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        const nodes = inject(NodesInjectionKey)!;
+
+        const resizeListener = ref<Element | null>(null);
+
+        onMounted(() => {
+            // ResizeListener exists because ResizeObserver's don't work when told to observe an SVG element
+            const resListener = resizeListener.value;
+            if (resListener != null) {
+                resizeObserver.observe(resListener);
+            }
+            updateBounds();
+        });
+
+        let isDirty = true;
+        function updateBounds() {
+            if (isDirty) {
+                isDirty = false;
+                nextTick(() => {
+                    if (resizeListener.value != null && props.onContainerResized) {
+                        // TODO don't overlap with Links.vue
+                        Object.values(nodes.value).forEach(
+                            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+                            node => (node!.rect = node?.element.getBoundingClientRect())
+                        );
+                        props.onContainerResized(resizeListener.value.getBoundingClientRect());
+                    }
+                    isDirty = true;
+                });
             }
         }
 
         return {
             particles,
-            particlesInit
+            particlesInit,
+            particlesLoaded,
+            resizeListener
         };
     }
 });
 </script>
+
+<style scoped>
+.not-fullscreen,
+.resize-listener {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    pointer-events: none;
+}
+</style>
diff --git a/src/features/particles/particles.tsx b/src/features/particles/particles.tsx
index 010bb36..4f460a9 100644
--- a/src/features/particles/particles.tsx
+++ b/src/features/particles/particles.tsx
@@ -1,39 +1,101 @@
-import ParticlesComponent from "./Particles.vue";
+import ParticlesComponent from "features/particles/Particles.vue";
+import { Container } from "tsparticles-engine";
 import { IEmitter } from "tsparticles-plugin-emitters/Options/Interfaces/IEmitter";
 import { EmitterInstance } from "tsparticles-plugin-emitters/EmitterInstance";
 import { EmitterContainer } from "tsparticles-plugin-emitters/EmitterContainer";
 import { Ref, shallowRef } from "vue";
-import { registerGameComponent } from "game/settings";
-import { jsx } from "features/feature";
+import { Component, GatherProps, getUniqueID, Replace, setDefault } from "features/feature";
+import { createLazyProxy } from "util/proxies";
 
-registerGameComponent(jsx(() => <ParticlesComponent onInit={onInit} />));
+export const ParticlesType = Symbol("Particles");
 
-const containerRef: Ref<null | EmitterContainer> = shallowRef(null);
+export interface ParticlesOptions {
+    fullscreen?: boolean;
+    zIndex?: number;
+    onContainerResized?: (boundingRect: DOMRect) => void;
+}
 
-let emittersToAdd: {
-    resolve: (value: EmitterInstance | PromiseLike<EmitterInstance>) => void;
-    options: IEmitter & { particles: Required<IEmitter>["particles"] };
-}[] = [];
+export interface BaseParticles {
+    id: string;
+    containerRef: Ref<null | (EmitterContainer & Container)>;
+    addEmitter: (
+        options: IEmitter & { particles: Required<IEmitter>["particles"] }
+    ) => Promise<EmitterInstance>;
+    removeEmitter: (emitter: EmitterInstance) => void;
+    type: typeof ParticlesType;
+    [Component]: typeof ParticlesComponent;
+    [GatherProps]: () => Record<string, unknown>;
+}
 
-export function addEmitter(
-    options: IEmitter & { particles: Required<IEmitter>["particles"] }
-): Promise<EmitterInstance> {
-    if (containerRef.value) {
-        // TODO why does addEmitter require a position parameter
-        return Promise.resolve(containerRef.value.addEmitter(options));
+export type Particles<T extends ParticlesOptions> = Replace<
+    T & BaseParticles,
+    {
+        fullscreen: undefined extends T["fullscreen"] ? true : T["fullscreen"];
+        zIndex: undefined extends T["zIndex"] ? 1 : T["zIndex"];
     }
-    return new Promise<EmitterInstance>(resolve => {
-        emittersToAdd.push({ resolve, options });
+>;
+
+export type GenericParticles = Replace<
+    Particles<ParticlesOptions>,
+    {
+        fullscreen: boolean;
+        zIndex: number;
+    }
+>;
+
+export function createParticles<T extends ParticlesOptions>(
+    optionsFunc: () => T & ThisType<Particles<T>>
+): Particles<T> {
+    return createLazyProxy(() => {
+        const particles: T & Partial<BaseParticles> = optionsFunc();
+        particles.id = getUniqueID("particles-");
+        particles.type = ParticlesType;
+        particles[Component] = ParticlesComponent;
+
+        particles.containerRef = shallowRef(null);
+        particles.addEmitter = (
+            options: IEmitter & { particles: Required<IEmitter>["particles"] }
+        ): Promise<EmitterInstance> => {
+            const genericParticles = particles as GenericParticles;
+            if (genericParticles.containerRef.value) {
+                // TODO why does addEmitter require a position parameter
+                return Promise.resolve(genericParticles.containerRef.value.addEmitter(options));
+            }
+            return new Promise<EmitterInstance>(resolve => {
+                emittersToAdd.push({ resolve, options });
+            });
+        };
+        particles.removeEmitter = (emitter: EmitterInstance) => {
+            // TODO I can't find a proper way to remove an emitter without accessing private functions
+            emitter.emitters.removeEmitter(emitter);
+        };
+
+        let emittersToAdd: {
+            resolve: (value: EmitterInstance | PromiseLike<EmitterInstance>) => void;
+            options: IEmitter & { particles: Required<IEmitter>["particles"] };
+        }[] = [];
+
+        function onInit(container: EmitterContainer & Container) {
+            (particles as GenericParticles).containerRef.value = container;
+            emittersToAdd.forEach(({ resolve, options }) => resolve(container.addEmitter(options)));
+            emittersToAdd = [];
+        }
+
+        setDefault(particles, "fullscreen", true);
+        setDefault(particles, "zIndex", 1);
+        particles.onContainerResized = particles.onContainerResized?.bind(particles);
+
+        particles[GatherProps] = function (this: GenericParticles) {
+            const { id, fullscreen, zIndex, onContainerResized } = this;
+            return {
+                id,
+                fullscreen,
+                zIndex,
+                onContainerResized,
+                onInit
+            };
+        };
+
+        return particles as unknown as Particles<T>;
     });
 }
-
-export function removeEmitter(emitter: EmitterInstance) {
-    // TODO I can't find a proper way to remove an emitter without accessing private functions
-    emitter.emitters.removeEmitter(emitter);
-}
-
-function onInit(container: EmitterContainer) {
-    containerRef.value = container;
-    emittersToAdd.forEach(({ resolve, options }) => resolve(container.addEmitter(options)));
-    emittersToAdd = [];
-}