How to extend the poker engine with new game variants, payment providers, admin panel pages, API endpoints, and WebSocket events.
Adding a New Game Variant
The engine uses the HandEvaluatorFactory pattern to dispatch hand evaluation to variant-specific implementations. Adding a new variant requires implementing the IHandEvaluator interface and registering it in the factory.
Step 1: Implement IHandEvaluator
Create your evaluator in src/game/hand-evaluator/evaluator-factory.ts, alongside the existing ones, or in a new file imported there:
// src/game/hand-evaluator/my-variant-evaluator.ts
import { Card } from '../engine/card.js';
import type { HandResult } from './hand-rank.js';
import type { IHandEvaluator } from './evaluator-factory.js';
import { evaluate7CardHand } from './evaluator.js';
export class MyVariantEvaluator implements IHandEvaluator {
evaluate(holeCards: Card[], communityCards?: Card[]): HandResult {
// Validate card counts for your variant
if (holeCards.length !== 2) {
throw new Error(`MyVariant requires exactly 2 hole cards, got ${holeCards.length}`);
}
// Implement your hand evaluation logic here
// Use evaluate5CardHand() or evaluate7CardHand() from './evaluator.js' as building blocks
const allCards = [...holeCards, ...(communityCards ?? [])];
return evaluate7CardHand(allCards);
}
}
Existing implementations to reference:
HoldEmEvaluator— standard 7-card evaluationOmahaEvaluator— 2+3 rule with combination enumeration (4 or 5 hole cards)ShortDeckEvaluator— modified hand rankings (flush beats full house)StudEvaluator— 7 hole cards, no community cards
Step 2: Add to the Prisma GameType Enum
Edit prisma/schema.prisma:
enum GameType {
TEXAS_HOLDEM
OMAHA
OMAHA_5
OMAHA_HI_LO
SHORT_DECK
STUD_7_CARD
STUD_HI_LO
MIXED_GAME
MY_VARIANT // Add your variant here
}
Then run:
npx prisma migrate dev --name add_my_variant
Step 3: Register in the Factory
Open src/game/hand-evaluator/evaluator-factory.ts and add your case:
// src/game/hand-evaluator/evaluator-factory.ts
import { MyVariantEvaluator } from './my-variant-evaluator.js';
export class HandEvaluatorFactory {
static create(gameType: GameType): IHandEvaluator {
switch (gameType) {
case GameType.TEXAS_HOLDEM:
return new HoldEmEvaluator();
// ... existing cases ...
case GameType.MY_VARIANT:
return new MyVariantEvaluator();
default:
throw new Error(`Unknown game type: ${gameType}. No evaluator registered for this variant.`);
}
}
}
Step 4: Handle Hi-Lo Variants
If your variant has a hi-lo showdown (like Omaha Hi-Lo or Stud Hi-Lo), the table routes to runHiLoShowdown() instead of runShowdown(). Add your variant to the isHiLo condition in src/game/table/table-actions.ts, and handle it in runHiLoShowdown() in src/game/flow/showdown.ts.
Step 5: Configure the Game Engine
Add variant-specific configuration where the engine switches on game type:
| Concern | File |
|---|---|
| Dealing pattern | createDealingPattern() in src/game/engine/dealing-engine.ts |
| Betting rounds and state progression | getVariantStateConfig() in src/game/flow/variant-state-config.ts |
| Default betting limit | getVariantBettingLimit() in src/game/betting/betting-limit-utils.ts |
| Bring-in games | usesBringIn() in src/game/betting/bring-in.ts |
| Mixed-game rotation and labels | src/game/flow/mixed-game-engine.ts |
| Hand start flow | src/game/table/table.ts |
| Bot strategy | src/game/bot/decision-engine.ts |
Step 6: Write Tests
Add tests in src/game/hand-evaluator/evaluator-factory.test.ts:
it('creates MyVariantEvaluator for MY_VARIANT', () => {
const evaluator = HandEvaluatorFactory.create(GameType.MY_VARIANT);
expect(evaluator).toBeInstanceOf(MyVariantEvaluator);
});
it('evaluates MY_VARIANT hands correctly', () => {
const evaluator = new MyVariantEvaluator();
const result = evaluator.evaluate([/* hole cards */], [/* community cards */]);
expect(result.rank).toBe(HandRank.TWO_PAIR);
});
Adding a New Payment Provider
Payment providers are classes that implement IPaymentProvider from src/services/payment-gateway.service.ts. There are two ways to add one, and both are covered in full in the Payment Providers guide.
As a plugin (no core changes). Register the class from your plugin’s register() function:
export function register(registry) {
registry.registerProvider('my_provider', MyProvider);
registry.registerProviderMeta('my_provider', {
displayName: 'My Provider',
currencies: ['USD'],
defaultActiveDeposit: false,
defaultActiveWithdraw: false,
});
}
The engine creates the payment system row on activation. Credentials can come from the plugin’s configFields or the payment system’s config. See the Plugin Development Guide.
In core. Create the provider in src/services/providers/my-provider.provider.ts, end the file with registerProvider('my_provider', MyProvider), and add a side-effect import alongside the others in src/app.ts:
// src/app.ts
import './services/providers/my-provider.provider.js';
Inbound webhooks arrive at POST /api/v1/webhooks/payment/:paymentSystemId. Add your provider’s signature handling to src/services/payment-webhook.service.ts.
Provider credentials are stored AES-256-GCM encrypted in the payment system’s config by the gateway. Administrators manage them through the admin panel, so a provider needs no settings service of its own.
Adding a New Admin Panel Page
Step 1: Create the Page Component
// client/src/pages/admin/my-feature/MyFeaturePage.tsx
import { useState, useEffect } from 'react';
import { apiClient } from '../../../api/client';
export function MyFeaturePage() {
const [data, setData] = useState([]);
useEffect(() => {
apiClient.get('/admin/my-feature').then(setData);
}, []);
return (
<div>
<h1>My Feature</h1>
{/* Page content */}
</div>
);
}
Step 2: Add the Route
// client/src/routes/index.tsx
const MyFeaturePage = lazy(() => import('@/pages/admin/my-feature/MyFeaturePage').then(m => ({ default: m.MyFeaturePage })));
// Inside the children of the '/admin' route:
{ path: 'my-feature', element: <Lazy><MyFeaturePage /></Lazy> },
Step 3: Add Navigation Link
Add an item to a group in the navGroups array in client/src/layouts/AdminLayout.tsx. The layout hides it from users without the required feature:
{ to: '/admin/my-feature', labelKey: 'nav.admin.myFeature', defaultLabel: 'My Feature', requiredFeature: 'my-feature:read' },
Step 4: Define the Feature Permission
Add your feature to ADMIN_FEATURES in src/api/middleware/auth.ts:
export const ADMIN_FEATURES = {
// ... existing features ...
'my-feature:read': 'b', // Moderator and above
'my-feature:edit': 'a1', // Admin and above
} as const;
Adding a New API Endpoint
Step 1: Define the Schema
// src/models/schemas.ts
import { z } from 'zod';
export const myResourceSchema = z.object({
name: z.string().min(1).max(100),
value: z.number().positive(),
});
export type MyResourceInput = z.infer<typeof myResourceSchema>;
Step 2: Create the Service
// src/services/my-resource.service.ts
import { prisma } from '../config/database.js';
import { NotFoundError } from '../models/errors.js';
export async function createMyResource(input: MyResourceInput, userId: string) {
return prisma.myResource.create({
data: { ...input, userId },
});
}
export async function getMyResource(id: string) {
const resource = await prisma.myResource.findUnique({ where: { id } });
if (!resource) throw new NotFoundError('MyResource');
return resource;
}
Step 3: Create the Route Handler
// src/api/routes/my-resource.routes.ts
import { Router } from 'express';
import { authenticate } from '../middleware/auth.js';
import { validateBody, validateParams } from '../middleware/validation.js';
import { myResourceSchema } from '../../models/schemas.js';
import { z } from 'zod';
import * as myResourceService from '../../services/my-resource.service.js';
const router = Router();
router.post('/',
authenticate,
validateBody(myResourceSchema),
async (req, res, next) => {
try {
const resource = await myResourceService.createMyResource(req.body, req.user!.sub);
res.status(201).json({ success: true, data: resource });
} catch (error) {
next(error);
}
}
);
router.get('/:id',
authenticate,
validateParams(z.object({ id: z.string().uuid() })),
async (req, res, next) => {
try {
const resource = await myResourceService.getMyResource(req.params.id);
res.json({ success: true, data: resource });
} catch (error) {
next(error);
}
}
);
export default router;
Step 4: Register the Routes
Mount the router in src/app.ts with the other API routes, and add the export to src/api/routes/index.ts if you import it from there:
// src/app.ts
import myResourceRoutes from './api/routes/my-resource.routes.js';
app.use('/api/v1/my-resources', myResourceRoutes);
Step 5: Error Handling
All route errors should be passed to next(error). The global error handler in src/api/middleware/error-handler.ts converts known error types to appropriate HTTP status codes:
| Error Class | HTTP Status |
|---|---|
ValidationError | 400 |
AuthenticationError | 401 |
AuthorizationError | 403 |
FeatureTierError | 403 |
NotFoundError | 404 |
ConflictError | 409 |
RateLimitError | 429 |
Adding a New WebSocket Event
WebSocket events use Socket.io with typed event maps. Server types live in src/websocket/types/events.ts, which is the single source of truth for the server. The client-facing copy is packages/shared/src/types/events.ts, so add new events to both.
Step 1: Add Event Types
// src/websocket/types/events.ts and packages/shared/src/types/events.ts
// Server → Client events
export interface ServerToClientEvents {
// ... existing events ...
my_event: (data: MyEventData) => void;
}
// Client → Server events
export interface ClientToServerEvents {
// ... existing events ...
my_action: (data: MyActionData, callback?: (response: MyActionResponse) => void) => void;
}
// Define the data types
export interface MyEventData {
tableId: string;
payload: string;
}
export interface MyActionData {
tableId: string;
value: number;
}
export interface MyActionResponse {
success: boolean;
error?: string;
}
Step 2: Emit the Event from Server
// In a game engine handler or service:
import { broadcastToTable, sendToUser } from '../websocket/broadcast.js';
// Emit to everyone at a table (players and spectators)
await broadcastToTable(tableId, 'my_event', { tableId, payload: 'data' });
// Emit to a specific user
await sendToUser(userId, 'my_event', { tableId, payload: 'data' });
Step 3: Handle the Event on Server
Client events are registered in src/websocket/connection-handler.ts, in the block marked EVENT HANDLERS (authenticated users only). Add a Zod schema for the payload in src/websocket/schemas/client-events.ts, and treat the callback as optional:
// src/websocket/connection-handler.ts
socket.on('my_action', async (data, callback) => {
try {
await processMyAction(socket.data.userId, data);
safeAck(callback, { success: true });
} catch (error) {
safeAck(callback, { success: false, error: 'Action failed' });
}
});
Step 4: Listen on the Client
// client/src/lib/socket.ts or a store
socket.on('my_event', (data) => {
console.log('Received my_event:', data);
// Update store state
});
// Emit an action
socket.emit('my_action', { tableId: 'xyz', value: 100 }, (response) => {
if (!response.success) console.error(response.error);
});
Extending the Database Schema
Adding a New Table
// prisma/schema.prisma
model MyResource {
id String @id @default(cuid())
name String
value Int
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("my_resources")
}
Then:
# Create a migration
npx prisma migrate dev --name add_my_resource_table
# Generate updated Prisma client
npx prisma generate
Adding a Column to an Existing Table
model User {
// ... existing fields ...
myNewField String? @default("default_value")
}
npx prisma migrate dev --name add_my_new_field_to_user
Important: Never use prisma migrate reset or prisma db push --force-reset against the development database — it wipes all data. Use prisma migrate dev for additive changes.