Authoring a module

merinaa module bank

Creates modules/bank/ with a ready-to-go shape. Open modules/merinaa.config.ts, import the module and add it to the array — that's it.

Folder convention

modules/bank/
├── module.config.ts     manifest (metadata, pages, overlays)
├── server/
│   ├── index.ts         default-exports the @Module class
│   ├── bank.module.ts   @Module
│   ├── bank.controller.ts @Controller
│   ├── bank.service.ts  @Injectable
│   ├── bank.guard.ts    @Guard (optional)
│   └── entities/        @entity classes for the ORM
├── client/
│   ├── index.ts         side-effect entry
│   └── bank.client.ts   decorator-based client controller
├── shared/              types used by both server and client
└── ui/
    ├── pages/           page components (one per registered page)
    ├── overlays/        overlay components
    └── components/      module-local reusable React bits

Server side

// bank.service.ts
import { Injectable, LoggerService } from '@merinaa/core';

@Injectable()
export class BankService {
    constructor(private logger: LoggerService) {}

    deposit(accountId: number, amount: number): void {
        this.logger.info(`[Bank] +$${amount} to ${accountId}`);
    }
}

// bank.controller.ts
import { Controller, OnClient, UseGuards } from '@merinaa/core';
import { BankService } from './bank.service';
import { BankGuard } from './bank.guard';

@Controller()
@UseGuards(BankGuard)
export class BankController {
    constructor(private bank: BankService) {}

    @OnClient('bank:deposit')
    deposit(src: number, accountId: number, amount: number): void {
        this.bank.deposit(accountId, amount);
    }
}

// bank.module.ts
@Module({
    providers: [BankService, BankController, BankGuard],
    exports: [BankService],
})
export class BankModule {}

// index.ts
export { BankModule as default } from './bank.module';

Client side

// client/bank.client.ts
import { showPage, hidePage } from '@merinaa/client';

RegisterCommand('bank', () => {
    showPage('bank', { accountId: 42 });
}, false);

showPage('bank', data) handles all of the following for you:

  • SetNuiFocus(true, true) (per the focus flag in module.config)
  • Starts a DisableControlAction tick loop for movement/camera
  • Binds Escape to hidePage() (per closeOnEscape)
  • Dispatches { type: 'bank:open', accountId: 42 } over NUI

UI side

Pages are always mounted — preloaded by createUI() before the first render. The component manages its own visibility via its internal useNuiEvent subscriptions. This is important: a page that uses useState(false) and only flips to true inside useNuiEvent('bank:open', ...) will render nothing until the event fires.

// ui/pages/BankPage.tsx
import { useNuiEvent } from '@merinaa/ui/hooks';
import { useState, useCallback } from 'react';

export default function BankPage() {
    const [visible, setVisible] = useState(false);
    const [data, setData] = useState<any>(null);

    useNuiEvent('bank:open', useCallback((payload) => {
        setData(payload);
        setVisible(true);
    }, []));

    useNuiEvent('bank:close', useCallback(() => {
        setVisible(false);
    }, []));

    if (!visible) return null;

    return <div>...</div>;
}

Overlays

Overlays live in ui/overlays/ and are registered under ui.overlays in module.config.ts.

  • alwaysMounted: true (default) — component mounts at app start, manages visibility internally. Use this for HUDs, persistent widgets, death screens.

  • alwaysMounted: false — component mounts/unmounts on overlay:show:<id> / overlay:hide:<id> events dispatched by showOverlay(id) / hideOverlay(id) on the client.

Where to next