@medieval-kit/registry
v0.2.1
Published
A lowpoly medieval procedural model library for Vibe3D and Three.js.
Maintainers
Readme
Medieval Kit
A lowpoly medieval model library for Vibe3D and Three.js. Installing a prop puts its source in your own tree, as readable TypeScript you can edit.


Install
bunx vibe3d init
bun add three
bun add -d @types/threeinit writes models.json and puts the shared Vibe3D runtime under
src/lib/vibe3d/, which the installed models import from. @types/three is a
dev dependency and not optional: three ships no type declarations of its own,
so without it the source you just installed does not typecheck.
init seeds models.json with @scifi-kit only, so add this registry to the
registries object it created:
{
"registries": {
"@medieval-kit": {
"source": "npm:@medieval-kit/registry",
"version": "latest"
}
}
}That map is how the namespace resolves. Without it vibe3d add stops with
Registry @medieval-kit is not configured in models.json.
Then take one model, or the lot:
bunx vibe3d add @medieval-kit/wooden-barrel
bunx vibe3d add @medieval-kitProject setup
The installed source imports through the @/ alias that models.json declares,
and it imports .ts files by their extension. Both have to be true on your side
as well. In tsconfig.json:
{
"compilerOptions": {
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"paths": { "@/*": ["./src/*"] }
}
}No baseUrl. It was removed in TypeScript 7, and paths resolves relative to
this file on its own. Verified against a clean project on 7.0.2.
The alias has to be set in your bundler too, because TypeScript's paths only
teaches the type checker. For Vite:
resolve: { alias: [{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }] }Models
37 models and one shared lib. Addresses are @medieval-kit/<id>. This table is
generated from the models themselves with bun scripts/catalog-table.ts. A
hand-written list goes stale on the first model you add, and this one already
did once.
| Model | Category | Triangles | Parts | Size (m) | Material slots | Animated |
| --- | --- | ---: | ---: | --- | --- | :-: |
| wooden-chest | Furniture | 552 | 4 | 0.86×0.51×0.48 | oak, iron | ✔ |
| wooden-barrel | Props | 1690 | 3 | 0.83×1.05×0.83 | oak, iron | |
| wooden-crate | Props | 1320 | 3 | 0.69×0.52×0.55 | oak, iron | |
| wooden-bucket | Props | 439 | 4 | 0.31×0.46×0.30 | oak, iron | |
| trestle-table | Furniture | 660 | 3 | 1.91×0.74×0.78 | oak | |
| wooden-bench | Furniture | 216 | 3 | 1.62×0.45×0.30 | oak | |
| wooden-stool | Furniture | 180 | 2 | 0.38×0.43×0.36 | oak | |
| pitch-torch | Lighting | 239 | 3 | 0.13×0.70×0.13 | oak, char, ember | ✔ |
| iron-lantern | Lighting | 480 | 4 | 0.15×0.30×0.17 | iron, glass, char, ember | ✔ |
| iron-anvil | Smithy | 256 | 6 | 0.54×0.75×0.41 | iron, steel, oak | |
| cart-wheel | Structure | 1136 | 4 | 1.04×1.06×0.19 | oak, iron | |
| log-pile | Props | 924 | 2 | 0.99×0.58×0.70 | oak | |
| hay-bale | Props | 910 | 3 | 1.04×0.42×0.43 | straw, cloth | |
| linen-sack | Props | 342 | 3 | 0.34×0.53×0.33 | cloth | |
| oak-tankard | Props | 390 | 4 | 0.11×0.16×0.14 | oak, iron | |
| straw-broom | Tools | 1048 | 3 | 0.30×1.22×0.30 | oak, straw, cloth | |
| bronze-bell | Props | 1048 | 4 | 0.51×0.68×0.36 | brass, iron, oak | ✔ |
| tavern-sign | Props | 876 | 3 | 0.38×2.29×0.93 | oak, iron | ✔ |
| wicker-basket | Props | 2326 | 3 | 0.34×0.16×0.35 | oak, produce | |
| leather-book | Props | 440 | 3 | 0.20×0.08×0.27 | leather, cloth, brass | |
| glass-phial | Props | 339 | 3 | 0.06×0.14×0.06 | glass, ember, oak, char | |
| coin-pouch | Props | 602 | 3 | 0.18×0.11×0.16 | leather, cloth, brass | |
| wooden-ladder | Structure | 440 | 2 | 0.49×2.20×0.06 | oak | |
| wooden-fence | Structure | 860 | 2 | 5.18×1.31×0.31 | oak | |
| wooden-hoe | Tools | 394 | 3 | 0.23×1.23×0.33 | oak, iron, steel | |
| wooden-shovel | Tools | 496 | 3 | 0.27×1.20×0.08 | oak, iron | |
| wooden-pitchfork | Tools | 392 | 3 | 0.27×1.58×0.15 | oak, iron | |
| iron-cauldron | Lighting | 1134 | 3 | 1.09×1.37×1.10 | stone, iron, char, ember | ✔ |
| hand-cart | Structure | 2044 | 4 | 0.87×0.68×2.49 | oak, iron | ✔ |
| vegetables | Props | 1718 | 2 | 0.60×0.12×0.59 | produce | |
| round-shield | Arms | 729 | 3 | 0.73×0.73×0.17 | oak, leather, iron | |
| forge-hearth | Smithy | 1426 | 4 | 2.06×1.78×0.87 | stone, char, ember, oak, leather, iron | ✔ |
| stone-well | Structure | 1376 | 4 | 1.88×2.00×1.28 | stone, oak, iron, cloth | ✔ |
| stone-trough | Structure | 344 | 2 | 1.52×0.45×0.60 | stone, water | |
| grindstone | Smithy | 568 | 4 | 1.24×0.95×0.70 | stone, oak, iron, water | ✔ |
| market-stall | Structure | 384 | 4 | 1.83×2.03×0.97 | oak, cloth | |
| post-mill | Structure | 1672 | 4 | 6.60×7.23×6.98 | oak, iron | ✔ |
39 518 triangles in total. The whole kit in one scene costs less than a single mid-complexity character model.
Every model depends on @medieval-kit/core, a shared support item holding the
palette, deterministic randomness, and the geometry vocabulary. Pulling a single
prop brings it along, so an individually installed crate still looks like it
came from the same catalogue as the barrel beside it.
Plain WebGL
This kit does not require WebGPU. It declares capabilities: [] and builds
on MeshStandardMaterial and MeshBasicMaterial, so it renders on
WebGLRenderer as well as WebGPURenderer.
Configuration
Models are procedural, not baked. Every prop exposes typed fields:
import { createModel } from '@/models/medieval-kit/wooden-barrel/model.ts'
const barrel = createModel({ staveCount: 17, taper: 0.22, seed: 42 })
scene.add(barrel.root)
barrel.configure({ hoopCount: 6 }) // rebuilds geometry inside a stable root
barrel.dispose()seed is a real field, not a debug knob. Variation is deterministic: the same
seed always produces the same prop, so previews, tests, and art direction stay
reproducible. Change the seed to get a different barrel from the same model.
Runtime anatomy
Each model follows the Vibe3D protocol:
rootkeeps its object identity for the model's whole lifetime.partsexpose stable semantic anchors. Attach your own lights, labels, or gameplay objects topart.anchor. They surviveconfigure(), because onlypart.contentis rebuilt.materialsresolve per slot and can be overridden. Materials you supply are borrowed: the model never disposes them.actionsandupdate(delta)carry interactive state. Eleven models have them, and they are the ✔ column in the table above:wooden-chest,pitch-torch,iron-lantern,bronze-bell,tavern-sign,iron-cauldron,hand-cart,forge-hearth,stone-well,grindstoneandpost-mill. Crank the grindstone and it turns and slows; set the mill turning and its sails go round; lower the well's bucket down the shaft:
const chest = createModel()
chest.actions.setOpen(true)
chest.update(deltaSeconds) // the lid eases open, frame-rate independent
const torch = createModel()
torch.actions.setLit(false)
torch.update(deltaSeconds) // flame flicker and light modulationA model that never receives update() simply stands still; nothing animates
behind your back. Reach for configure() when a user changes a setting; it
rebuilds topology and is not cheap. Anything that changes per frame belongs in
update().
Material slots
Thirteen slots, and each one exists because a single material could not carry the difference.
| Slot | What it covers | Why it is separate |
| --- | --- | --- |
| oak | timber | the baseline everything else is measured against |
| iron | forged iron, oxidised and matte | separate from steel: the difference is not in colour but in ROUGHNESS, and vertex colour cannot carry roughness |
| steel | steel polished by use | the anvil face, the shovel blade, the pitchfork tines |
| brass | bronze and copper | the bell, the coins |
| straw | straw, wicker, broom bristle | a bale declaring itself "oak" would be a lie told to the consumer |
| cloth | linen, sackcloth, rope | woven, so it takes light softly and never glints |
| leather | worked leather | darker and glossier than cloth at the same colour |
| glass | blown glass | transparent, depthWrite OFF, DoubleSide |
| produce | fruit and vegetable skin | given straw's roughness, an apple would look like dry grass |
| ember | flame | MeshBasicMaterial: it does not receive light, it emits |
| char | charcoal and pitch | the darkest thing in the kit, and it has to stay readable against shadow |
| water | standing water | a thin transparent film over whatever holds it: in the reference trough the basin reads hue 39.5, which is the hue of the STONE underneath, so it works through opacity rather than colour |
| stone | dressed and rubble masonry | its colour is close to weathered oak; what separates them at a glance is that stone scatters light completely flat, and roughness is not something vertex colour can carry |
ember also acts as a rule in two places: bodies in that slot are skipped
entirely while ambient occlusion and mottle are baked. The reason is simple: in an unlit material the vertex colour is the final
colour, so darkening it puts the flame out. The rule is attached to the slot itself rather than to a
per-model flag, so it cannot be forgotten.
Colour variation lives in the geometry, not in the materials: each of the barrel's 17 staves carries its own tone as vertex colours, and all of them share one material and therefore one draw call.
Building from source
bun build.ts # emit dist/registry.json
bunx vibe3d registry validate dist/registry.jsonThe build walks models/. A folder containing model.ts becomes a
vibe3d:model; any other folder becomes a vibe3d:lib. Registry dependencies
are derived from the source. An import from ../core/ makes
@medieval-kit/core a dependency automatically, so there is no hand-maintained
list to go stale.
drafts/ is deliberately outside that walk: the code is kept in the tree but
never reaches the published package, including its own drafts/README.md,
which is why this sentence does not link to it. A relative link on an npm
package page resolves against the repository, and there is nothing at the
other end of it for a reader of this page.
Released under the MIT License.
