Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: CI

on:
push:
branches: ['**']
pull_request:
branches: ['**']

jobs:
lint-typecheck:
name: Lint & Type-check (backend)
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI installs pnpm with version: latest, which can introduce unexpected breakages when pnpm releases new majors/minors. Pin pnpm to a known-good version (or at least a major) to keep CI reproducible with the checked-in lockfile.

Suggested change
version: latest
version: 9

Copilot uses AI. Check for mistakes.

- name: Setup Node 24
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build shared
run: pnpm run build:shared

- name: Lint backend
run: pnpm --filter @notblox/back run lint

- name: Type-check backend
run: pnpm --filter @notblox/back run build

docker-smoke:
name: Docker build & smoke test
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build image
uses: docker/build-push-action@v5
with:
context: .
load: true
tags: notblox-game-server:ci
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Start container
run: docker run -d --name smoke notblox-game-server:ci

- name: Wait for healthy
run: |
for i in $(seq 1 12); do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' smoke 2>/dev/null || echo 'missing')
echo "Attempt $i: $STATUS"
if [ "$STATUS" = "healthy" ]; then
echo "Container is healthy"
exit 0
fi
if [ "$STATUS" = "unhealthy" ]; then
echo "Container became unhealthy"
docker logs smoke
exit 1
fi
sleep 5
done
echo "Timed out waiting for container to become healthy"
docker logs smoke
exit 1

- name: Print logs
if: always()
run: docker logs smoke

- name: Stop container
if: always()
run: docker rm -f smoke
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,9 @@ COPY back/src ./back/src

# Run from back/ so Node resolves tsx from back/node_modules
WORKDIR /app/back

# Probe TCP port 8001 – container is healthy once the WS server is accepting connections
HEALTHCHECK --interval=5s --timeout=3s --start-period=15s --retries=3 \
CMD node -e "const n=require('net').createConnection(8001,'localhost'); n.on('connect',()=>{n.destroy();process.exit(0);}); n.on('error',()=>process.exit(1));"

CMD ["node", "--import", "tsx/esm", "src/sandbox.ts"]
9 changes: 9 additions & 0 deletions back/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import { defineConfig } from "eslint/config";

export default defineConfig([
{ files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } },
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This backend ESLint config sets languageOptions.globals to globals.browser, which omits Node globals like process/Buffer. That will cause no-undef (or similar) errors throughout the server code. Use Node (or a merge of Node + ES globals) for backend files, and only apply browser globals to frontend code if needed.

Suggested change
{ files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } },
{ files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.node } },

Copilot uses AI. Check for mistakes.
tseslint.configs.recommended,
Comment on lines +7 to +8
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eslint.config.ts is using flat-config style (defineConfig) but mixes in legacy config fields: plugins: { js } and extends: ["js/recommended"]. @eslint/js exports config objects (e.g. js.configs.recommended) rather than a plugin, and flat config extends should reference config objects (not strings). As written, ESLint 10 will likely fail to load or silently not apply the intended rules—please restructure the config to compose js.configs.recommended + the TypeScript ESLint configs directly.

Suggested change
{ files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } },
tseslint.configs.recommended,
{
...js.configs.recommended,
files: ["**/*.{js,mjs,cjs,ts,mts,cts}"],
languageOptions: {
...js.configs.recommended.languageOptions,
globals: {
...(js.configs.recommended.languageOptions?.globals ?? {}),
...globals.browser,
},
},
},
...tseslint.configs.recommended,

Copilot uses AI. Check for mistakes.
]);
Comment on lines +1 to +9
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new TS config file doesn’t follow the repository’s Prettier settings (single quotes, no semicolons, line width). This will introduce avoidable formatting diffs when the file is auto-formatted. Please run Prettier (or adjust the file) to match .prettierrc.

Suggested change
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import { defineConfig } from "eslint/config";
export default defineConfig([
{ files: ["**/*.{js,mjs,cjs,ts,mts,cts}"], plugins: { js }, extends: ["js/recommended"], languageOptions: { globals: globals.browser } },
tseslint.configs.recommended,
]);
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import { defineConfig } from 'eslint/config'
export default defineConfig([
{
files: ['**/*.{js,mjs,cjs,ts,mts,cts}'],
plugins: { js },
extends: ['js/recommended'],
languageOptions: { globals: globals.browser },
},
tseslint.configs.recommended,
])

Copilot uses AI. Check for mistakes.
13 changes: 8 additions & 5 deletions back/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,32 @@
"dev": "tsx watch src/sandbox.ts",
"build": "tsc --noEmit",
"start": "node --import tsx/esm src/sandbox.ts",
"lint": "eslint src/**/*.ts",
"format": "eslint src/**/*.ts --fix"
"lint": "eslint src/",
"format": "eslint src/ --fix"
},
"author": "iercann",
"license": "MIT",
"engines": {
"node": ">=24"
},
"overrides": {
"minimatch": "^10.2.1"
"minimatch": "^10.2.1",
"jiti": "latest"
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jiti is overridden to "latest", which makes installs non-reproducible and can break CI unexpectedly when a new major is released. Prefer pinning to a specific version/range (matching the lockfile) or removing the override unless there’s a concrete compatibility need.

Suggested change
"jiti": "latest"
"jiti": "^2.6.1"

Copilot uses AI. Check for mistakes.
},
"dependencies": {
"@notblox/shared": "workspace:*",
"tsx": "^4.0.0",
"@dimforge/rapier3d-compat": "^0.14.0",
"@notblox/shared": "workspace:*",
"dotenv": "^16.3.1",
"msgpackr": "^1.9.9",
"node-three-gltf": "^1.8.3",
"pako": "^2.1.0",
"rate-limiter-flexible": "^5.0.3",
"three": "^0.183.0",
"tsx": "^4.0.0",
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.57.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.0.0",
"@types/pako": "^2.0.3",
"@types/three": "^0.183.0",
Expand All @@ -42,6 +44,7 @@
"@typescript-eslint/parser": "latest",
"eslint": "latest",
"globals": "latest",
"jiti": "^2.6.1",
"typescript": "^5.7.3",
"typescript-eslint": "latest"
}
Expand Down
3 changes: 2 additions & 1 deletion back/src/ecs/component/WebsocketComponent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Component } from '@shared/component/Component.js'
import type { WebSocket } from 'uWebSockets.js'

export class WebSocketComponent extends Component {
/**
Expand All @@ -10,7 +11,7 @@ export class WebSocketComponent extends Component {
*/
constructor(
entityId: number,
public ws: any,
public ws: WebSocket<unknown>,
public isFirstSnapshotSent = false
) {
super(entityId)
Expand Down
3 changes: 2 additions & 1 deletion back/src/ecs/entity/Player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ import { ColorComponent } from '@shared/component/ColorComponent.js'
import { ServerMeshComponent } from '@shared/component/ServerMeshComponent.js'
import { TextComponent } from '@shared/component/TextComponent.js'
import { PhysicsPropertiesComponent } from '../component/physics/PhysicsPropertiesComponent.js'
import type { WebSocket } from 'uWebSockets.js'

export class Player {
entity: Entity

constructor(ws: WebSocket, initialX: number, initialY: number, initialZ: number) {
constructor(ws: WebSocket<unknown>, initialX: number, initialY: number, initialZ: number) {
this.entity = EntityManager.createEntity(SerializedEntityType.PLAYER)
// Tag
const playerComponent = new PlayerComponent(this.entity.id)
Expand Down
2 changes: 1 addition & 1 deletion back/src/ecs/system/network/NetworkSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export class NetworkSystem {
}

// Broadcasts a message to all connected clients.
private broadcast(entities: Entity[], message: any): void {
private broadcast(entities: Entity[], message: Uint8Array): void {
for (const entity of entities) {
const websocketComponent = entity.getComponent(WebSocketComponent)
if (websocketComponent) {
Expand Down
64 changes: 40 additions & 24 deletions back/src/ecs/system/network/WebsocketSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
HttpRequest,
HttpResponse,
SSLApp,
WebSocket,
us_listen_socket,
us_socket_context_t,
} from 'uWebSockets.js'
Comment on lines +7 to 10
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WebSocket appears to be used only as a TypeScript type here. Importing it as a runtime named export ({ WebSocket }) can break at runtime if uWebSockets.js doesn’t actually export WebSocket (many libraries only declare it in .d.ts). Please switch to a type-only import for WebSocket (and keep only runtime exports in the value import).

Suggested change
WebSocket,
us_listen_socket,
us_socket_context_t,
} from 'uWebSockets.js'
us_listen_socket,
us_socket_context_t,
} from 'uWebSockets.js'
import type { WebSocket } from 'uWebSockets.js'

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -38,7 +39,9 @@ import { EntityManager } from '@shared/system/EntityManager.js'
import { MessageListComponent } from '@shared/component/MessageComponent.js'
import { ChatComponent } from '../../component/tag/TagChatComponent.js'
import { WebSocketComponent } from '../../component/WebsocketComponent.js'
type MessageHandler = (ws: any, message: any) => void

type PlayerData = { player?: Player }
type MessageHandler = (ws: WebSocket<PlayerData>, message: ClientMessage) => void

export class WebsocketSystem {
private port: number = 8001
Expand Down Expand Up @@ -132,7 +135,7 @@ export class WebsocketSystem {
res.end(JSON.stringify(healthData))
})

app.ws('/*', {
app.ws<PlayerData>('/*', {
idleTimeout: 32,
maxBackpressure: 1024,
maxPayloadLength: 512,
Expand Down Expand Up @@ -160,8 +163,8 @@ export class WebsocketSystem {
return
}

res.upgrade(
{}, // WebSocket handler will go here
res.upgrade<PlayerData>(
{},
req.getHeader('sec-websocket-key'),
req.getHeader('sec-websocket-protocol'),
req.getHeader('sec-websocket-extensions'),
Expand All @@ -178,15 +181,21 @@ export class WebsocketSystem {
}

private initializeMessageHandlers() {
this.addMessageHandler(ClientMessageType.INPUT, this.handleInputMessage.bind(this))
this.addMessageHandler(ClientMessageType.CHAT_MESSAGE, this.handleChatMessage.bind(this))
this.addMessageHandler(
ClientMessageType.INPUT,
this.handleInputMessage.bind(this) as MessageHandler
)
this.addMessageHandler(
Comment on lines +184 to +188
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The as MessageHandler casts here weaken type-safety and can hide mismatches between ClientMessageType and the handler payload types. Consider typing the handler map as a discriminated/mapped type keyed by ClientMessageType (or using a switch in onMessage) so handlers can accept their specific message shapes without assertions.

Copilot uses AI. Check for mistakes.
ClientMessageType.CHAT_MESSAGE,
this.handleChatMessage.bind(this) as MessageHandler
)
this.addMessageHandler(
ClientMessageType.PROXIMITY_PROMPT_INTERACT,
this.handleProximityPromptInteractMessage.bind(this)
this.handleProximityPromptInteractMessage.bind(this) as MessageHandler
)
this.addMessageHandler(
Comment on lines 192 to 196
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These casts to MessageHandler remove compile-time guarantees that the handler matches the message type. A typed handler map keyed by ClientMessageType would keep this safe without as assertions.

Copilot uses AI. Check for mistakes.
ClientMessageType.SET_PLAYER_NAME,
this.handleSetPlayerNameMessage.bind(this)
this.handleSetPlayerNameMessage.bind(this) as MessageHandler
)
}
Comment on lines 196 to 200
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as above: the as MessageHandler casts hide mismatches between the message discriminator and the handler signature. Prefer a typed mapping (or narrowing in onMessage) so the compiler enforces correct handler/message pairing.

Copilot uses AI. Check for mistakes.

Expand All @@ -198,8 +207,8 @@ export class WebsocketSystem {
this.messageHandlers.delete(type)
}

private onMessage(ws: any, message: any) {
const clientMessage: ClientMessage = unpack(message)
private onMessage(ws: WebSocket<PlayerData>, message: ArrayBuffer) {
const clientMessage: ClientMessage = unpack(Buffer.from(message))
const handler = this.messageHandlers.get(clientMessage.t)
if (handler) {
handler(ws, clientMessage)
Expand All @@ -209,12 +218,12 @@ export class WebsocketSystem {
// TODO: Create EventOnPlayerConnect and EventOnPlayerDisconnect to respects ECS
// Might be useful to query the chat and send a message to all players when a player connects or disconnects
// Also could append scriptable events to be triggered on connect/disconnect depending on the game
private async onConnect(ws: any) {
private async onConnect(ws: WebSocket<PlayerData>) {
const ipBuffer = ws.getRemoteAddressAsText() as ArrayBuffer
const ip = Buffer.from(ipBuffer).toString()
if (await this.isRateLimited(ip)) {
// Respond to the client indicating that the connection is rate limited
return ws.close(429, 'Rate limit exceeded')
return ws.close()
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onConnect still says it will respond to the client when rate-limited, but now it just calls ws.close() with no close code/reason. If you want clients to handle this, send a meaningful WebSocket close code/reason (valid WS codes are 1000–4999) or reject the upgrade with an HTTP status before upgrading.

Suggested change
return ws.close()
return ws.close(1013, 'Rate limit exceeded')

Copilot uses AI. Check for mistakes.
}
const player = new Player(ws, Math.random() * 5, 5, Math.random() * 5)
const connectionMessage: ConnectionMessage = {
Expand All @@ -223,7 +232,7 @@ export class WebsocketSystem {
tickRate: config.SERVER_TICKRATE,
}
// player.entity.addComponent(new RandomizeComponent(player.entity.id))
ws.player = player
ws.getUserData().player = player
ws.send(NetworkSystem.compress(connectionMessage), true)

EventSystem.addEvent(
Expand All @@ -236,12 +245,12 @@ export class WebsocketSystem {
this.players.push(player)
}

private onDrain(ws: any) {
private onDrain(ws: WebSocket<PlayerData>) {
console.log('WebSocket backpressure: ' + ws.getBufferedAmount())
}

private onClose(ws: any) {
const disconnectedPlayer: Player = ws.player
private onClose(ws: WebSocket<PlayerData>) {
const disconnectedPlayer = ws.getUserData().player
if (!disconnectedPlayer) {
console.error('Disconnect: Player not found?', ws)
return
Expand All @@ -260,8 +269,8 @@ export class WebsocketSystem {
entity.removeComponent(WebSocketComponent)
}

private async handleInputMessage(ws: any, message: InputMessage) {
const player: Player = ws.player
private handleInputMessage(ws: WebSocket<PlayerData>, message: InputMessage) {
const player = ws.getUserData().player
if (!player) {
console.error(`Player with WS ${ws} not found.`)
return
Expand All @@ -283,9 +292,13 @@ export class WebsocketSystem {
this.inputProcessingSystem.receiveInputPacket(player.entity, message)
}

private handleChatMessage(ws: any, message: ChatMessage) {
private handleChatMessage(ws: WebSocket<PlayerData>, message: ChatMessage) {
console.log('Chat message received', message)
const player: Player = ws.player
const player = ws.getUserData().player
if (!player) {
console.error(`Player with WS ${ws} not found.`)
return
}

const { content } = message
if (!content || typeof content !== 'string' || content.length === 0) {
Expand All @@ -301,8 +314,11 @@ export class WebsocketSystem {

EventSystem.addEvent(new MessageEvent(player.entity.id, playerName, content))
}
private handleProximityPromptInteractMessage(ws: any, message: ProximityPromptInteractMessage) {
const player: Player = ws.player
private handleProximityPromptInteractMessage(
ws: WebSocket<PlayerData>,
message: ProximityPromptInteractMessage
) {
const player = ws.getUserData().player
if (!player) {
console.error(`Player with WS ${ws} not found.`)
return
Expand All @@ -311,8 +327,8 @@ export class WebsocketSystem {
EventSystem.addEvent(new ProximityPromptInteractEvent(player.entity.id, eId))
}

private handleSetPlayerNameMessage(ws: any, message: SetPlayerNameMessage) {
const player: Player = ws.player
private handleSetPlayerNameMessage(ws: WebSocket<PlayerData>, message: SetPlayerNameMessage) {
const player = ws.getUserData().player
if (!player) {
console.error(`Player with WS ${ws} not found.`)
return
Expand Down
4 changes: 2 additions & 2 deletions back/src/scripts/defaultScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ const cube = new Cube({
})
const proximityPromptComponent = new ProximityPromptComponent(cube.entity.id, {
text: 'Press E to change color',
onInteract: (interactingEntity) => {
onInteract: () => {
cube.entity
.getComponent(DynamicRigidBodyComponent)!
.body!.applyImpulse(new Rapier.Vector3(0, 5, 0), true)
Expand Down Expand Up @@ -171,7 +171,7 @@ for (let i = 1; i < 10; i++) {
const y = 5
const z = 20 * i

let wheelConfig: Record<string, number> = {}
let wheelConfig: Record<string, number>
if (i < 5) {
wheelConfig = {
frontLeft: Math.max(1, i / 2.5),
Expand Down
Loading