Skip to content

Commit 4a4070a

Browse files
r1tsuucodex
andcommitted
feat: add crafting-table block entities
Add a reusable server-side block-entity system and wire crafting tables through the authoritative use-block path. Persist crafting-table entities, add dedicated crafting-table textures and atlas tiles, and cover the new behavior with tests and follow-up plans. Co-authored-by: Codex <codex@openai.com>
1 parent bae24ae commit 4a4070a

30 files changed

Lines changed: 1258 additions & 7 deletions

ARCHITECTURE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,12 @@ World-level entity state:
165165
- One `EntityRegistry` for actor ids in the active world.
166166
- Component stores for player identity, transform, mode, movement, inventory, session presence, and persistence.
167167
- Component stores for dropped-item transform, stack contents, and pickup cooldown.
168+
- Component stores for block-entity type and block position, with a dedicated
169+
`BlockEntitySystem` owning entity-backed blocks such as crafting tables.
168170

169171
Chunks are not entities — chunk data stays coordinate-addressed. World generation, chunk persistence, and chunk resend decisions stay in `AuthoritativeWorld`.
172+
Interactive blocks still live in chunk voxel data for placement/breaking, but their
173+
server-owned state, use handling, and future per-tick simulation live in the block-entity layer.
170174

171175
#### World entry warmup
172176

@@ -179,6 +183,7 @@ Chunks are not entities — chunk data stays coordinate-addressed. World generat
179183
- Chunk generation on demand.
180184
- Draining queued gameplay intents on the authoritative tick boundary.
181185
- Simulating dropped items and other world systems once per authoritative tick.
186+
- Simulating block entities once per authoritative tick, including future furnace/chest/door behavior.
182187
- Batching replication after each tick so clients observe coherent world-state updates.
183188
- Validating and applying block mutations.
184189
- Handling chat-driven commands such as `/gamemode` and `/timeset`.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ The authoritative server autosaves periodically; successful autosaves are printe
165165

166166
- First-person movement with grounded collision, jump physics, and creative flight toggled by double-tapping `Space`
167167
- Server-authoritative block breaking, placing, inventory mutation, dropped-item spawning, and pickups
168+
- Entity-backed crafting table blocks with server-side interaction handling and a reusable block-entity foundation for future interactive blocks
168169
- Authoritative fixed-step world tick loop for both local worker and dedicated server modes
169170
- Periodic server-side autosave on the authoritative tick loop, plus manual `/save` while in-game
170171
- Per-player position, rotation, gamemode, and inventory persistence inside each world
@@ -234,7 +235,7 @@ Content authoring and the asset pipeline are documented in [`ARCHITECTURE.md`](.
234235
## Possible Future Work
235236

236237
- Survival systems: health, fall damage, death/respawn, hunger, and healing
237-
- Crafting flows: player crafting and a crafting table
238+
- Crafting flows: player `2x2` crafting, crafting-table `3x3` recipes, and result-slot UX
238239
- Tool progression with mining tiers, faster harvesting, and block hardness
239240
- Furnace and smelting mechanics with fuel and ore processing
240241
- Day/night cycle and stronger atmosphere changes across time of day

apps/cli/src/default-voxel-tile-sources.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,103 @@ const createPlanksPixel = (x: number, y: number): Rgba => {
195195
return color
196196
}
197197

198+
const createCraftingTableTopPixel = (x: number, y: number): Rgba => {
199+
const border = rgba(88, 61, 34)
200+
const woodA = rgba(170, 129, 75)
201+
const woodB = rgba(151, 113, 65)
202+
const seam = rgba(112, 78, 43)
203+
const grid = rgba(69, 48, 28)
204+
const highlight = rgba(204, 162, 100)
205+
206+
if (x === 0 || y === 0 || x === 15 || y === 15) {
207+
return border
208+
}
209+
210+
const innerX = x >= 2 && x <= 13
211+
const innerY = y >= 2 && y <= 13
212+
if (innerX && innerY && (x === 5 || x === 9 || y === 5 || y === 9)) {
213+
return grid
214+
}
215+
216+
const cellX = x <= 4 ? 0 : x <= 8 ? 1 : 2
217+
const cellY = y <= 4 ? 0 : y <= 8 ? 1 : 2
218+
const base = (cellX + cellY) % 2 === 0 ? woodA : woodB
219+
let color = base
220+
221+
if ((x + y) % 5 === 0) {
222+
color = tint(color, 8)
223+
} else if ((hash2d(x, y, 0x9bcaf1) & 0xf) <= 2) {
224+
color = tint(color, -10)
225+
}
226+
227+
if (innerX && innerY && ((x >= 2 && x <= 4) || (x >= 10 && x <= 13))) {
228+
color = tint(color, 5)
229+
}
230+
if (y === 3 || y === 7 || y === 12) {
231+
color = seam
232+
}
233+
if (
234+
(x === 3 || x === 7 || x === 12 || y === 3 || y === 7 || y === 12) &&
235+
innerX &&
236+
innerY
237+
) {
238+
color = highlight
239+
}
240+
241+
return color
242+
}
243+
244+
const createCraftingTableBottomPixel = (x: number, y: number): Rgba => {
245+
const base = rgba(120, 88, 50)
246+
const groove = rgba(84, 58, 33)
247+
if (y % 4 === 0) {
248+
return groove
249+
}
250+
251+
let color = tint(base, (Math.floor(y / 4) % 2 === 0 ? 1 : -1) * 10)
252+
if ((hash2d(x, y, 0x52ab19) & 0x1f) < 5) {
253+
color = tint(color, 8)
254+
} else if ((x === 2 || x === 13) && y > 1 && y < 14) {
255+
color = tint(color, -12)
256+
}
257+
return color
258+
}
259+
260+
const createCraftingTableSidePixel = (x: number, y: number): Rgba => {
261+
const frame = rgba(91, 62, 37)
262+
const panel = rgba(146, 107, 63)
263+
const shadow = rgba(106, 74, 44)
264+
const strap = rgba(63, 44, 28)
265+
const accent = rgba(184, 145, 84)
266+
267+
if (x === 0 || y === 0 || x === 15 || y === 15) {
268+
return frame
269+
}
270+
271+
if (x === 3 || x === 12 || y === 3 || y === 12) {
272+
return shadow
273+
}
274+
275+
if ((x === 7 || x === 8) && y >= 4 && y <= 11) {
276+
return strap
277+
}
278+
279+
let color = panel
280+
if ((hash2d(x, y, 0x1f4ca2) & 0xf) <= 2) {
281+
color = tint(color, 10)
282+
} else if (((x + y) & 0x3) === 0) {
283+
color = tint(color, -8)
284+
}
285+
286+
const inLeftPanel = x >= 4 && x <= 6 && y >= 4 && y <= 11
287+
const inRightPanel = x >= 9 && x <= 11 && y >= 4 && y <= 11
288+
if ((inLeftPanel || inRightPanel) && (x === 5 || x === 10 || y === 5 || y === 10)) {
289+
color = accent
290+
}
291+
292+
return color
293+
}
294+
198295
const createCobblestonePixel = (x: number, y: number): Rgba => {
199296
const MORTAR = rgba(75, 75, 77)
200297
const COLS = 3,
@@ -373,6 +470,9 @@ const DEFAULT_TILE_PIXEL_FACTORIES: Record<AtlasTileId, (x: number, y: number) =
373470
'diamond-ore': createDiamondOrePixel,
374471
arm: createArmPixel,
375472
glass: createGlassPixel,
473+
'crafting-table-top': createCraftingTableTopPixel,
474+
'crafting-table-bottom': createCraftingTableBottomPixel,
475+
'crafting-table-side': createCraftingTableSidePixel,
376476
}
377477

378478
export const buildDefaultVoxelTilePixels = (tileId: AtlasTileId): Uint8Array => {
194 Bytes
Loading
234 Bytes
Loading
306 Bytes
Loading
507 Bytes
Loading

apps/client/src/app/play-controller.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,15 @@ export class PlayController {
421421
}
422422

423423
if (hit && input.placeBlockPressed) {
424+
const targetedBlockId = worldRuntime.world.getBlock(hit.hit.x, hit.hit.y, hit.hit.z)
425+
if (targetedBlockId === BLOCK_IDS.craftingTable) {
426+
adapter.eventBus.send({
427+
type: 'useBlock',
428+
payload: { x: hit.hit.x, y: hit.hit.y, z: hit.hit.z },
429+
})
430+
return null
431+
}
432+
424433
const selectedSlot = getSelectedInventorySlot(worldRuntime.inventory)
425434
const placedBlockId = getPlacedBlockIdForItem(selectedSlot.itemId)
426435
if (selectedSlot.count <= 0 || placedBlockId === null) {

packages/core/src/server/authoritative-world.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { getInventorySlot } from '../world/inventory.ts'
3232
import { getPlacedBlockIdForItem, isValidItemId } from '../world/items.ts'
3333
import { createGeneratedChunk, getTerrainHeight } from '../world/terrain.ts'
3434
import { worldToChunkCoord } from '../world/world.ts'
35+
import { BlockEntitySystem } from './block-entity-system.ts'
3536
import { type DroppedItemSimulationResult, DroppedItemSystem } from './dropped-item-system.ts'
3637
import { LightingSystem } from './lighting-system.ts'
3738
import { PlayerSystem } from './player-system.ts'
@@ -128,6 +129,7 @@ export class AuthoritativeWorld {
128129
private readonly chunks = new Map<string, ServerChunkEntry>()
129130
private readonly entityState = new WorldEntityState()
130131
private readonly playerSystem: PlayerSystem
132+
private readonly blockEntitySystem: BlockEntitySystem
131133
private readonly droppedItemSystem: DroppedItemSystem
132134
private readonly lightingSystem = new LightingSystem()
133135
private readonly initialization: Promise<void>
@@ -144,6 +146,16 @@ export class AuthoritativeWorld {
144146
this.entityState,
145147
options?.createInventory,
146148
)
149+
this.blockEntitySystem = new BlockEntitySystem(
150+
this.world.name,
151+
this.storage,
152+
this.entityState,
153+
{
154+
getWorld: () => this,
155+
getActivePlayers: () => this.playerSystem.getActivePlayers(),
156+
getPlayerSnapshot: (entityId) => this.getPlayerSnapshot(entityId),
157+
},
158+
)
147159
this.droppedItemSystem = new DroppedItemSystem(
148160
this.world.name,
149161
this.storage,
@@ -166,6 +178,18 @@ export class AuthoritativeWorld {
166178
return this.playerSystem.getPlayerName(entityId)
167179
}
168180

181+
public getPlayerSnapshot(entityId: EntityId): PlayerSnapshot | null {
182+
if (!this.entityState.hasPlayerEntity(entityId)) {
183+
return null
184+
}
185+
186+
return this.playerSystem.getPlayerSnapshot(entityId)
187+
}
188+
189+
public getActivePlayers(): PlayerSnapshot[] {
190+
return this.playerSystem.getActivePlayers()
191+
}
192+
169193
public async joinPlayer(playerName: PlayerName): Promise<{
170194
clientPlayer: PlayerSnapshot
171195
players: PlayerSnapshot[]
@@ -294,6 +318,16 @@ export class AuthoritativeWorld {
294318
return this.playerSystem.interactInventorySlot(entityId, slot)
295319
}
296320

321+
public async useBlock(
322+
entityId: EntityId,
323+
worldX: number,
324+
worldY: number,
325+
worldZ: number,
326+
): Promise<void> {
327+
await this.ensureInitialized()
328+
await this.blockEntitySystem.useBlock(entityId, worldX, worldY, worldZ)
329+
}
330+
297331
public async givePlayerItem(
298332
entityId: EntityId,
299333
itemId: ItemId,
@@ -447,6 +481,7 @@ export class AuthoritativeWorld {
447481
entry.chunk.set(coords.local.x, coords.local.y, coords.local.z, blockId)
448482
entry.chunk.revision += 1
449483
entry.saveDirty = true
484+
await this.blockEntitySystem.syncBlock(worldX, worldY, worldZ, blockId)
450485

451486
const lightingChanged = this.relightMutationAffectedChunks(
452487
coords.chunk,
@@ -475,6 +510,7 @@ export class AuthoritativeWorld {
475510
await this.ensureInitialized()
476511
const savedChunks = await this.flushDirtyChunks(false)
477512
await this.playerSystem.save()
513+
await this.blockEntitySystem.save()
478514
await this.droppedItemSystem.save()
479515
await this.storage.saveWorldTime(this.world.name, this.lightingSystem.getTimeState())
480516
this.world = await this.storage.touchWorld(this.world.name, Date.now())
@@ -512,6 +548,11 @@ export class AuthoritativeWorld {
512548
)
513549
break
514550
}
551+
case 'useBlock': {
552+
await this.useBlock(intent.playerEntityId, intent.x, intent.y, intent.z)
553+
this.drainBlockEntityMessages(result)
554+
break
555+
}
515556
case 'interactInventorySlot': {
516557
this.mergeInventoryUpdate(
517558
result,
@@ -538,6 +579,8 @@ export class AuthoritativeWorld {
538579
}
539580
}
540581

582+
await this.blockEntitySystem.tick(deltaSeconds)
583+
this.drainBlockEntityMessages(result)
541584
this.mergeSimulationResult(result, await this.stepSimulation(deltaSeconds))
542585
result.worldTime = this.lightingSystem.advanceTime(Math.max(1, Math.round(deltaSeconds * 20)))
543586
return result
@@ -712,6 +755,10 @@ export class AuthoritativeWorld {
712755
result.playerUpdates.push(player)
713756
}
714757

758+
private drainBlockEntityMessages(result: WorldTickResult): void {
759+
result.chatMessages.push(...this.blockEntitySystem.drainChatMessages())
760+
}
761+
715762
private mergeSimulationResult(result: WorldTickResult, simulation: WorldSimulationResult): void {
716763
for (const update of simulation.inventoryUpdates) {
717764
this.mergeInventoryUpdate(result, update.playerEntityId, update.inventory)

0 commit comments

Comments
 (0)