victor HF Staff commited on
Commit
a2d0320
·
verified ·
1 Parent(s): 441bf0c

Initial deployment of Mini World game

Browse files
Files changed (10) hide show
  1. .gitignore +6 -0
  2. CLAUDE.md +82 -0
  3. Dockerfile +33 -0
  4. README.md +39 -5
  5. aiService.ts +129 -0
  6. bun.lockb +0 -0
  7. gameState.ts +401 -0
  8. index.html +69 -0
  9. package.json +14 -0
  10. server.ts +103 -0
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .parcel-cache
2
+ dist
3
+ node_modules
4
+ .aider*
5
+ .env
6
+ messages.json
CLAUDE.md ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ # Install dependencies
9
+ bun install
10
+
11
+ # Start the server (requires HF_TOKEN env var)
12
+ HF_TOKEN=<your-token> bun server.ts
13
+
14
+ # Or using npm script
15
+ HF_TOKEN=<your-token> bun start
16
+ ```
17
+
18
+ The server runs at http://localhost:7860 (or set `PORT` env var)
19
+
20
+ ## Architecture
21
+
22
+ This is a real-time AI-powered exploration game where an AI agent navigates a procedurally-generated 2D emoji world.
23
+
24
+ ### Core Files
25
+
26
+ - **server.ts** - HTTP server using Bun's native `serve()`. Handles SSE streaming via `/game-stream` endpoint and static file serving. Contains the main AI loop that calls `AIService.getNextAction()` every 1 second.
27
+
28
+ - **aiService.ts** - AI inference layer using OpenAI SDK with HuggingFace router (`https://router.huggingface.co/v1`). Manages system prompts, message history (limited to last 15 entries), and action validation. Returns `{ action: "move"|"pick", detail: "up"|"down"|"left"|"right" }`.
29
+
30
+ - **gameState.ts** - Game world and state management. Creates a 600x600 grid using Perlin noise for terrain generation (trees, rocks, empty spaces). Handles character movement, object picking, and broadcasts state updates to all connected SSE clients.
31
+
32
+ - **index.html** - Frontend using vanilla JS + Tailwind CSS. Connects via EventSource to `/game-stream` and renders the map, inventory, and action history in real-time.
33
+
34
+ ### Data Flow
35
+
36
+ ```
37
+ Client connects → /game-stream SSE
38
+
39
+ GameState.sendUpdate() broadcasts current state
40
+
41
+ Every 1 second: runAI() → AIService.getNextAction()
42
+
43
+ AI returns JSON action → GameState.handleAction()
44
+
45
+ State update broadcast to all clients
46
+ ```
47
+
48
+ ### Environment Variables
49
+
50
+ - `HF_TOKEN` (required) - HuggingFace API token for model access
51
+ - `HF_MODEL` or `OPENAI_MODEL` (optional) - Override default model (`zai-org/GLM-4.7-Flash:novita`)
52
+
53
+ ### Map Symbols
54
+
55
+ - 🚶 Character (player)
56
+ - 💎 Diamond (collectible)
57
+ - 🌳 Tree (obstacle)
58
+ - 🪨 Rock (obstacle)
59
+ - ⬜ Empty space (walkable)
60
+
61
+ ## Testing
62
+
63
+ The AI loop only starts when a client connects to `/game-stream`. To test:
64
+
65
+ ```bash
66
+ # Start server in background
67
+ HF_TOKEN=<token> bun server.ts &
68
+
69
+ # Test config endpoint
70
+ curl http://localhost:3000/config
71
+
72
+ # Connect to game stream (triggers AI loop)
73
+ curl http://localhost:3000/game-stream
74
+ ```
75
+
76
+ Server logs show `Raw AI response:` for each AI action and `Rendering around character at (x, y)` for position updates.
77
+
78
+ ### AI Response Handling
79
+
80
+ - AI may return JSON wrapped in markdown code blocks (``` ```json...``` ```) - this is stripped automatically
81
+ - Invalid/malformed responses fall back to `{"action":"move","detail":"right"}`
82
+ - Valid actions: `move` or `pick` with direction `up`, `down`, `left`, or `right`
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use official Bun image
2
+ FROM oven/bun:1-alpine AS base
3
+
4
+ # Set up user with ID 1000 (required by Hugging Face Spaces)
5
+ RUN adduser -D -u 1000 user
6
+
7
+ # Switch to the user
8
+ USER user
9
+
10
+ # Set environment
11
+ ENV HOME=/home/user \
12
+ PATH=/home/user/.local/bin:$PATH
13
+
14
+ # Set working directory
15
+ WORKDIR $HOME/app
16
+
17
+ # Copy package files first for better caching
18
+ COPY --chown=user package.json bun.lockb ./
19
+
20
+ # Install dependencies
21
+ RUN bun install --frozen-lockfile --production
22
+
23
+ # Copy application files
24
+ COPY --chown=user . .
25
+
26
+ # Expose port 7860 (Hugging Face Spaces default)
27
+ EXPOSE 7860
28
+
29
+ # Set default port for Hugging Face Spaces
30
+ ENV PORT=7860
31
+
32
+ # Run the server
33
+ CMD ["bun", "server.ts"]
README.md CHANGED
@@ -1,10 +1,44 @@
1
  ---
2
  title: Mini World
3
- emoji: 🌖
4
- colorFrom: indigo
5
- colorTo: gray
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Mini World
3
+ emoji: 🌍
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
  ---
9
 
10
+ # Mini World
11
+
12
+ A real-time AI-powered exploration game where an AI agent navigates a procedurally-generated 2D emoji world.
13
+
14
+ ## Features
15
+
16
+ - 🚶 AI agent that autonomously explores a 600x600 world
17
+ - 💎 Collectible diamonds scattered across the map
18
+ - 🌳🪨 Procedurally generated terrain using Perlin noise
19
+ - Real-time updates via Server-Sent Events (SSE)
20
+
21
+ ## Environment Variables
22
+
23
+ Set these in your Hugging Face Space settings:
24
+
25
+ - `HF_TOKEN` (required) - Your Hugging Face API token for model access
26
+
27
+ ## Local Development
28
+
29
+ ```bash
30
+ # Install dependencies
31
+ bun install
32
+
33
+ # Start the server
34
+ HF_TOKEN=<your-token> bun server.ts
35
+ ```
36
+
37
+ The server runs at http://localhost:7860 (or set `PORT` env var)
38
+
39
+ ## Architecture
40
+
41
+ - **server.ts** - HTTP server with SSE streaming
42
+ - **aiService.ts** - AI inference using OpenAI SDK with HuggingFace router
43
+ - **gameState.ts** - Game world and state management
44
+ - **index.html** - Frontend UI
aiService.ts ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { OpenAI } from "openai";
2
+
3
+ // Configuration - use specified model with fallback to env vars
4
+ export const MODEL =
5
+ process.env.HF_MODEL ||
6
+ process.env.OPENAI_MODEL ||
7
+ "zai-org/GLM-4.7-Flash:novita";
8
+
9
+ const HF_TOKEN = process.env.HF_TOKEN;
10
+ if (!HF_TOKEN) {
11
+ throw new Error(
12
+ "HF_TOKEN environment variable is required for Hugging Face Router authentication."
13
+ );
14
+ }
15
+
16
+ const SYS_PROMPT = `
17
+ # Welcome to the you own exploration game
18
+
19
+ You are acting as an explorer in a 2D world game seen from a top-down perspective. Everything in this world, including objects and landmarks, is represented by emoji.
20
+ The most important emoji is 🚶 (it's you).
21
+
22
+ ## Important concepts
23
+
24
+ ### Turn-based
25
+
26
+ You can take one action per turn, then you'll get the result of your action before you can take another action.
27
+
28
+ ### Goals
29
+
30
+ Your primary goal is to collect as many diamonds (💎) as possible. A good strategy is to explore efficiently and avoid revisiting the same tiles while searching for diamonds.
31
+
32
+ ## Available Actions
33
+
34
+ You must respond your next action in JSON format with what action you want to take. Give no explanations just return the JSON.
35
+
36
+ ### Move
37
+
38
+ This action allows you to move one emoji away from your current location, choosing between the four available direct tiles around you (🚶): "up", "right", "down", or "left".
39
+ You are only allowed to move on the empty ⬜ square, so always make sure that your target square is a ⬜.
40
+
41
+ { "action": "move", "detail": move direction, can be "up", "right", "down", or "left" }
42
+
43
+ ### Pick
44
+
45
+ This action allows you to pick up an object from an adjacent tile (up, right, down, or left).
46
+ The object will be added to your inventory and removed from the map.
47
+
48
+ { "action": "pick", "detail": direction of the object to pick, can be "up", "right", "down", or "left" }`;
49
+
50
+ // OpenAI client configured for HuggingFace router
51
+ const client = new OpenAI({
52
+ baseURL: "https://router.huggingface.co/v1",
53
+ apiKey: HF_TOKEN,
54
+ });
55
+
56
+ export class AIService {
57
+ /**
58
+ * Get the next action from the AI agent
59
+ * Returns a validated action object { action: "move"|"pick", detail: "up"|"down"|"left"|"right" }
60
+ */
61
+ static async getNextAction(
62
+ map: string,
63
+ history: any[]
64
+ ): Promise<{ action: string; detail: string }> {
65
+ const messages: OpenAI.ChatCompletionMessageParam[] = [
66
+ {
67
+ role: "system",
68
+ content: SYS_PROMPT,
69
+ },
70
+ ...history.slice(-15),
71
+ {
72
+ role: "user",
73
+ content: `Please decide on what action to do next and provide the corresponding JSON response. Here is the current map:
74
+ ${map}
75
+ `,
76
+ },
77
+ ];
78
+
79
+ // Log messages for debugging
80
+ Bun.write("messages.json", JSON.stringify(messages, null, 2));
81
+
82
+ try {
83
+ const chatCompletion = await client.chat.completions.create({
84
+ model: MODEL,
85
+ messages,
86
+ temperature: 0.7,
87
+ });
88
+
89
+ let content = chatCompletion.choices[0].message.content?.trim() ?? "";
90
+ console.log("Raw AI response:", content);
91
+
92
+ // Strip markdown code blocks if present
93
+ if (content.startsWith("```")) {
94
+ content = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
95
+ }
96
+
97
+ // Parse and validate the response
98
+ const parsed = JSON.parse(content);
99
+
100
+ if (this.isValidAction(parsed)) {
101
+ return {
102
+ action: parsed.action,
103
+ detail: parsed.detail.toLowerCase(),
104
+ };
105
+ }
106
+
107
+ throw new Error("Invalid action format");
108
+ } catch (error) {
109
+ console.error("Error getting AI action:", error);
110
+ // Return a safe fallback action if anything fails
111
+ return { action: "move", detail: "right" };
112
+ }
113
+ }
114
+
115
+ private static isValidAction(obj: any): boolean {
116
+ if (!obj || typeof obj !== "object" || typeof obj.action !== "string") {
117
+ return false;
118
+ }
119
+
120
+ if (obj.action === "move" || obj.action === "pick") {
121
+ return (
122
+ typeof obj.detail === "string" &&
123
+ ["up", "down", "left", "right"].includes(obj.detail.toLowerCase())
124
+ );
125
+ }
126
+
127
+ return false;
128
+ }
129
+ }
bun.lockb ADDED
Binary file (1.32 kB). View file
 
gameState.ts ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const emojis = {
2
+ tree: "🌳",
3
+ rock: "🪨",
4
+ character: "🚶",
5
+ blank: "⬜",
6
+ diamond: "💎",
7
+ };
8
+
9
+ type World = {
10
+ width: number;
11
+ height: number;
12
+ data: string[];
13
+ };
14
+
15
+ type GameObject = {
16
+ x: number;
17
+ y: number;
18
+ emoji: string;
19
+ };
20
+
21
+ type Character = GameObject & {
22
+ inventory: GameObject[];
23
+ };
24
+
25
+ export class GameState {
26
+ private world: World;
27
+ private character: Character;
28
+ private objects: GameObject[] = [];
29
+ private controllers: Set<ReadableStreamDefaultController<string>> = new Set();
30
+ private history: any[] = [];
31
+
32
+ constructor() {
33
+ this.initializeGame();
34
+ }
35
+
36
+ private initializeGame() {
37
+ const worldWidth = 600;
38
+ const worldHeight = 600;
39
+ this.world = this.createWorld(worldWidth, worldHeight);
40
+ this.world = this.generateTerrain(
41
+ this.world,
42
+ [
43
+ { emoji: "🌳", threshold: 0.4 },
44
+ { emoji: "🪨", threshold: 0.5 },
45
+ ],
46
+ 0.1
47
+ );
48
+
49
+ const emptyLocation = this.getRandomEmptyLocation(this.world);
50
+ this.character = {
51
+ ...emptyLocation,
52
+ emoji: emojis.character,
53
+ inventory: [],
54
+ };
55
+
56
+ // Spawn 500 diamonds at random locations
57
+ for (let i = 0; i < 500; i++) {
58
+ this.spawnAtRandom(emojis.diamond);
59
+ }
60
+ }
61
+
62
+ setController(controller: ReadableStreamDefaultController<string>) {
63
+ this.controllers.add(controller);
64
+ try {
65
+ // Send initial state immediately when controller is set
66
+ this.sendUpdate();
67
+ } catch (error) {
68
+ console.error("Error sending initial state:", error);
69
+ this.controllers.delete(controller);
70
+ }
71
+ }
72
+
73
+ removeController(controller: ReadableStreamDefaultController<string>) {
74
+ this.controllers.delete(controller);
75
+ }
76
+
77
+ getState() {
78
+ return {
79
+ map: this.renderAround(this.world, [this.character, ...this.objects], 6),
80
+ inventory: this.character.inventory.map((item) => item.emoji),
81
+ history: this.history,
82
+ };
83
+ }
84
+
85
+ async handleAction(action: any) {
86
+ try {
87
+ if (action.action === "move") {
88
+ this.moveObject(this.character, action.detail, this.world);
89
+ this.history.push({
90
+ role: "assistant",
91
+ content: JSON.stringify(action),
92
+ });
93
+ this.history.push({
94
+ role: "user",
95
+ content: `valid action - input your next action in JSON.\nCurrent map:\n\n${this.renderAround(
96
+ this.world,
97
+ [this.character, ...this.objects],
98
+ 5
99
+ )}\n\nInventory: ${this.character.inventory
100
+ .map((item) => item.emoji)
101
+ .join(", ")}`,
102
+ });
103
+ } else if (action.action === "pick") {
104
+ this.pickObject(action.detail);
105
+ this.history.push({
106
+ role: "assistant",
107
+ content: JSON.stringify(action),
108
+ });
109
+ this.history.push({
110
+ role: "user",
111
+ content: `valid action - input your next action in JSON.\nCurrent map:\n\n${this.renderAround(
112
+ this.world,
113
+ [this.character, ...this.objects],
114
+ 3
115
+ )}\n\nInventory: ${this.character.inventory
116
+ .map((item) => item.emoji)
117
+ .join(", ")}`,
118
+ });
119
+ }
120
+ } catch (err) {
121
+ this.history.push({
122
+ role: "user",
123
+ content: "This action is invalid. Please try something else.",
124
+ });
125
+ }
126
+
127
+ this.sendUpdate();
128
+ }
129
+
130
+ private sendUpdate() {
131
+ if (this.controllers.size === 0) return;
132
+
133
+ const state = this.getState();
134
+ const data = `data: ${JSON.stringify(state)}\n\n`;
135
+
136
+ for (const controller of this.controllers) {
137
+ try {
138
+ controller.enqueue(data);
139
+ } catch (error) {
140
+ console.error("Error sending update to client:", error);
141
+ this.controllers.delete(controller);
142
+ }
143
+ }
144
+ }
145
+
146
+ // Helper functions moved from main.js
147
+ private createWorld(width: number, height: number): World {
148
+ return {
149
+ width,
150
+ height,
151
+ data: new Array(width * height).fill(emojis.blank),
152
+ };
153
+ }
154
+
155
+ private getIndex(world: World, x: number, y: number): number {
156
+ return y * world.width + x;
157
+ }
158
+
159
+ private setTile(world: World, x: number, y: number, tile: string): World {
160
+ const newWorld = { ...world };
161
+ newWorld.data[this.getIndex(newWorld, x, y)] = tile;
162
+ return newWorld;
163
+ }
164
+
165
+ private getTile(world: World, x: number, y: number): string {
166
+ return world.data[this.getIndex(world, x, y)];
167
+ }
168
+
169
+ private isEmpty(world: World, x: number, y: number): boolean {
170
+ if (x < 0 || y < 0 || x >= world.width || y >= world.height) {
171
+ return false;
172
+ }
173
+ return this.getTile(world, x, y) === emojis.blank;
174
+ }
175
+
176
+ private getRandomEmptyLocation(world: World) {
177
+ let x = Math.floor(Math.random() * world.width);
178
+ let y = Math.floor(Math.random() * world.height);
179
+
180
+ while (!this.isEmpty(world, x, y)) {
181
+ x = Math.floor(Math.random() * world.width);
182
+ y = Math.floor(Math.random() * world.height);
183
+ }
184
+
185
+ return { x, y };
186
+ }
187
+
188
+ private moveObject(object: Character, direction: string, world: World) {
189
+ const moveMapping: Record<string, { dx: number; dy: number }> = {
190
+ up: { dx: 0, dy: -1 },
191
+ down: { dx: 0, dy: 1 },
192
+ left: { dx: -1, dy: 0 },
193
+ right: { dx: 1, dy: 0 },
194
+ };
195
+
196
+ const movement = moveMapping[direction];
197
+ if (
198
+ movement &&
199
+ this.isEmpty(world, object.x + movement.dx, object.y + movement.dy)
200
+ ) {
201
+ object.x += movement.dx;
202
+ object.y += movement.dy;
203
+ } else {
204
+ throw new Error("invalid action");
205
+ }
206
+ }
207
+
208
+ private generateTerrain(
209
+ world: World,
210
+ emojiThresholds: Array<{ emoji: string; threshold: number }>,
211
+ noiseScale: number
212
+ ): World {
213
+ const permutations = this.generatePermutations();
214
+ let newWorld = { ...world };
215
+
216
+ for (let y = 0; y < newWorld.height; y++) {
217
+ for (let x = 0; x < newWorld.width; x++) {
218
+ const noiseValue =
219
+ (this.perlin2d(x * noiseScale, y * noiseScale, permutations) + 1) / 2;
220
+
221
+ let found = false;
222
+ for (const { emoji, threshold } of emojiThresholds) {
223
+ if (noiseValue < threshold) {
224
+ newWorld = this.setTile(newWorld, x, y, emoji);
225
+ found = true;
226
+ break;
227
+ }
228
+ }
229
+
230
+ if (!found) {
231
+ newWorld = this.setTile(newWorld, x, y, emojis.blank);
232
+ }
233
+ }
234
+ }
235
+ return newWorld;
236
+ }
237
+
238
+ private generatePermutations(): number[] {
239
+ const p = new Array(256);
240
+ for (let i = 0; i < 256; i++) {
241
+ p[i] = Math.floor(Math.random() * 256);
242
+ }
243
+ return p.concat(p);
244
+ }
245
+
246
+ private fade(t: number): number {
247
+ return t * t * t * (t * (t * 6 - 15) + 10);
248
+ }
249
+
250
+ private lerp(t: number, a: number, b: number): number {
251
+ return a + t * (b - a);
252
+ }
253
+
254
+ private grad(hash: number, x: number, y: number): number {
255
+ const h = hash & 3;
256
+ const u = h < 2 ? x : y;
257
+ const v = h < 2 ? y : x;
258
+ return (h & 1 ? -u : u) + (h & 2 ? -2 * v : 2 * v);
259
+ }
260
+
261
+ private perlin2d(x: number, y: number, permutations: number[]): number {
262
+ const X = Math.floor(x) & 255;
263
+ const Y = Math.floor(y) & 255;
264
+
265
+ x -= Math.floor(x);
266
+ y -= Math.floor(y);
267
+
268
+ const u = this.fade(x);
269
+ const v = this.fade(y);
270
+
271
+ const A = permutations[X] + Y;
272
+ const B = permutations[X + 1] + Y;
273
+
274
+ return this.lerp(
275
+ v,
276
+ this.lerp(
277
+ u,
278
+ this.grad(permutations[A], x, y),
279
+ this.grad(permutations[B], x - 1, y)
280
+ ),
281
+ this.lerp(
282
+ u,
283
+ this.grad(permutations[A + 1], x, y - 1),
284
+ this.grad(permutations[B + 1], x - 1, y - 1)
285
+ )
286
+ );
287
+ }
288
+
289
+ private renderAround(
290
+ world: World,
291
+ objects: GameObject[],
292
+ distance: number
293
+ ): string {
294
+ if (!objects.length) return "No objects to render";
295
+ const mainObject = objects[0]; // Use first object (character) as reference point
296
+ console.log(
297
+ `Rendering around character at (${mainObject.x}, ${mainObject.y}) with ${objects.length} total objects`
298
+ );
299
+ const startPosition = {
300
+ x: mainObject.x - distance,
301
+ y: mainObject.y - distance,
302
+ };
303
+
304
+ const areaWidth = 2 * distance + 1;
305
+ const areaHeight = 2 * distance + 1;
306
+
307
+ return this.render(world, objects, startPosition, areaWidth, areaHeight);
308
+ }
309
+
310
+ public spawnObject(x: number, y: number, emoji: string): GameObject {
311
+ if (!this.isEmpty(this.world, x, y)) {
312
+ throw new Error("Cannot spawn object on non-empty space");
313
+ }
314
+
315
+ const newObject: GameObject = { x, y, emoji };
316
+ this.objects.push(newObject);
317
+ this.sendUpdate();
318
+ return newObject;
319
+ }
320
+
321
+ public spawnAtRandom(emoji: string): GameObject {
322
+ const location = this.getRandomEmptyLocation(this.world);
323
+ const obj = this.spawnObject(location.x, location.y, emoji);
324
+ console.log(
325
+ `Spawned ${emoji} at (${obj.x}, ${obj.y}). Total objects: ${this.objects.length}`
326
+ );
327
+ return obj;
328
+ }
329
+
330
+ private getAdjacentObject(
331
+ x: number,
332
+ y: number,
333
+ direction: string
334
+ ): GameObject | null {
335
+ const moveMapping: Record<string, { dx: number; dy: number }> = {
336
+ up: { dx: 0, dy: -1 },
337
+ down: { dx: 0, dy: 1 },
338
+ left: { dx: -1, dy: 0 },
339
+ right: { dx: 1, dy: 0 },
340
+ };
341
+
342
+ const movement = moveMapping[direction];
343
+ if (!movement) return null;
344
+
345
+ const targetX = x + movement.dx;
346
+ const targetY = y + movement.dy;
347
+
348
+ return (
349
+ this.objects.find((obj) => obj.x === targetX && obj.y === targetY) || null
350
+ );
351
+ }
352
+
353
+ private pickObject(direction: string) {
354
+ const object = this.getAdjacentObject(
355
+ this.character.x,
356
+ this.character.y,
357
+ direction
358
+ );
359
+
360
+ if (!object) {
361
+ throw new Error("No object found in that direction");
362
+ }
363
+
364
+ // Add to inventory
365
+ this.character.inventory.push(object);
366
+
367
+ // Remove from world objects
368
+ this.objects = this.objects.filter((obj) => obj !== object);
369
+ }
370
+
371
+ private render(
372
+ world: World,
373
+ objects: Character[] = [],
374
+ startPosition = { x: 0, y: 0 },
375
+ areaWidth = world.width,
376
+ areaHeight = world.height
377
+ ): string {
378
+ let output = "";
379
+
380
+ for (let y = startPosition.y; y < startPosition.y + areaHeight; y++) {
381
+ for (let x = startPosition.x; x < startPosition.x + areaWidth; x++) {
382
+ if (x >= 0 && y >= 0 && x < world.width && y < world.height) {
383
+ const objectAtPosition = objects.find(
384
+ (obj) => obj.x === x && obj.y === y
385
+ );
386
+
387
+ if (objectAtPosition) {
388
+ output += objectAtPosition.emoji;
389
+ } else {
390
+ output += this.getTile(world, x, y);
391
+ }
392
+ } else {
393
+ output += "🪨";
394
+ }
395
+ }
396
+ output += "\n";
397
+ }
398
+
399
+ return output;
400
+ }
401
+ }
index.html ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" style="display: flex; align-items: center; justify-items: center; width: 100vw">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Mini World</title>
7
+ <link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet" />
8
+ </head>
9
+ <body class="flex gap-4 items-start p-12">
10
+ <div class="flex flex-col gap-4">
11
+ <div id="world-container" class="text-3xl flex-none whitespace-nowrap leading-none"></div>
12
+ <div id="inventory-container" class="text-2xl"></div>
13
+ </div>
14
+ <div class="flex flex-col pt-1 gap-4">
15
+ <p class="font-mono rounded-lg">
16
+ model: <span id="model-display" class="text-blue-600">loading…</span>
17
+ </p>
18
+ <div class="gap-2 flex flex-col">
19
+ Agent
20
+ <div class="flex items-center gap-2 rounded-lg border bg-gray-50 px-3 py-2">
21
+ <span class="text-3xl">🚶</span>
22
+ <span class="text-sm text-gray-600">Explorer</span>
23
+ </div>
24
+ </div>
25
+ <p>Goal: Explore as much as possible</p>
26
+ <div id="history-container" class="flex max-w-sm flex-wrap gap-2 pt-2"></div>
27
+ </div>
28
+ <script>
29
+ // Fetch runtime config (model name) and reflect in UI
30
+ fetch('/config')
31
+ .then(r => r.json())
32
+ .then(cfg => {
33
+ const el = document.getElementById('model-display');
34
+ if (el && cfg?.model) el.textContent = cfg.model;
35
+ })
36
+ .catch(() => {});
37
+
38
+ const eventSource = new EventSource('/game-stream');
39
+
40
+ eventSource.onmessage = (event) => {
41
+ const state = JSON.parse(event.data);
42
+ document.getElementById('world-container').innerText = state.map;
43
+ document.getElementById('inventory-container').innerText =
44
+ `Inventory: ${state.inventory.join(' ')}`;
45
+
46
+ const history = state.history
47
+ .filter(({ role }) => role === "assistant")
48
+ .map(({ content }) => {
49
+ const parsed = JSON.parse(content);
50
+ return `
51
+ <div class="flex text-sm">
52
+ <div class="flex items-baseline gap-2 rounded-full border bg-gray-100 px-2 py-1">
53
+ <div class="h-2 w-2 flex-none rounded-full bg-green-500"></div>
54
+ ${parsed.action}: <span class="text-gray-500">${parsed.detail}</span>
55
+ </div>
56
+ </div>`;
57
+ })
58
+ .join("");
59
+
60
+ document.getElementById('history-container').innerHTML = history;
61
+ };
62
+
63
+ // Clean up when the page is closed
64
+ window.addEventListener('beforeunload', () => {
65
+ eventSource.close();
66
+ });
67
+ </script>
68
+ </body>
69
+ </html>
package.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "mini-world",
3
+ "version": "1.0.0",
4
+ "author": "",
5
+ "main": "main.js",
6
+ "dependencies": {
7
+ "openai": "^5.18.0"
8
+ },
9
+ "description": "",
10
+ "license": "ISC",
11
+ "scripts": {
12
+ "start": "bun server.ts"
13
+ }
14
+ }
server.ts ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { serve } from "bun";
2
+ import { readFile } from "fs/promises";
3
+ import { join } from "path";
4
+ import { GameState } from "./gameState";
5
+ import { AIService, MODEL } from "./aiService";
6
+
7
+ const PORT = parseInt(process.env.PORT || "7860");
8
+ const ROOT_DIR = import.meta.dir;
9
+ const gameState = new GameState(); // Single shared game state
10
+
11
+ const mimeTypes: Record<string, string> = {
12
+ ".html": "text/html",
13
+ ".js": "application/javascript",
14
+ ".css": "text/css",
15
+ ".map": "application/octet-stream",
16
+ ".ico": "image/x-icon",
17
+ ".png": "image/png",
18
+ ".jpg": "image/jpeg",
19
+ ".jpeg": "image/jpeg",
20
+ ".gif": "image/gif",
21
+ ".svg": "image/svg+xml",
22
+ ".webp": "image/webp",
23
+ ".json": "application/json",
24
+ };
25
+
26
+ serve({
27
+ port: PORT,
28
+ async fetch(req) {
29
+ const url = new URL(req.url);
30
+ const pathname = url.pathname;
31
+
32
+ // Simple config endpoint to expose runtime info to the UI
33
+ if (pathname === "/config") {
34
+ return new Response(JSON.stringify({ model: MODEL }), {
35
+ headers: { "Content-Type": "application/json" },
36
+ });
37
+ }
38
+
39
+ // Handle SSE connection
40
+ if (pathname === "/game-stream") {
41
+ const stream = new ReadableStream<string>({
42
+ start(controller) {
43
+ gameState.setController(controller);
44
+ gameState.sendUpdate();
45
+
46
+ // Initial state will be sent by gameState
47
+
48
+ // Start AI agent loop
49
+ const runAI = async () => {
50
+ const state = gameState.getState();
51
+ const action = await AIService.getNextAction(
52
+ state.map,
53
+ state.history
54
+ );
55
+ await gameState.handleAction(action);
56
+
57
+ // Schedule next AI action
58
+ setTimeout(runAI, 1000);
59
+ };
60
+
61
+ runAI();
62
+ },
63
+ cancel() {
64
+ // No need to cleanup since we're using a shared state
65
+ },
66
+ });
67
+
68
+ return new Response(stream, {
69
+ headers: {
70
+ "Content-Type": "text/event-stream",
71
+ "Cache-Control": "no-cache",
72
+ Connection: "keep-alive",
73
+ },
74
+ });
75
+ }
76
+
77
+ // Serve static files
78
+ if (pathname === "/" || pathname === "/index.html") {
79
+ const data = await readFile(join(ROOT_DIR, "index.html"));
80
+ return new Response(data, {
81
+ headers: { "Content-Type": "text/html" },
82
+ });
83
+ }
84
+
85
+ const filePath = join(ROOT_DIR, pathname);
86
+ try {
87
+ const data = await readFile(filePath);
88
+ const ext = filePath.slice(filePath.lastIndexOf("."));
89
+ const mimeType = mimeTypes[ext] || "application/octet-stream";
90
+
91
+ return new Response(data, {
92
+ status: 200,
93
+ headers: {
94
+ "Content-Type": mimeType,
95
+ },
96
+ });
97
+ } catch (error) {
98
+ return new Response("Not Found", { status: 404 });
99
+ }
100
+ },
101
+ });
102
+
103
+ console.log(`Server is running at http://localhost:${PORT}`);