@@ -1,122 +1,566 @@
# Lance 3D
☾
# Core API
Set style ps1 / n64 / saturn
# Lance 3D Manual (v1)
set_style("ps1" | "n64" | "saturn")
Lance 3D is a focused 3D fantasy-console engine for making games with a PS1 / N64 / Saturn kind of visual personality. It’ s immediate-mode: you load some assets, set a camera, draw every frame, ship.
Get time since game boot, in seconds **time()**
Get runtime statistics
stat(name: "draw_calls" | "triangles" | "fps" | "memory_bytes")
Game code is written in **CellScript** (not JavaScript):
```text
https://cell-lang.org/cellscript/
```
set_lighting({
sun_dir: [0.3,-1,0.2], // normalized internally
sun_color: [1,1,1], // rgb
ambient: [0.25,0.25,0.25] // rgb
Networking is **v2** .
---
## Specifications
* **Target FPS:** 60
* **VSync:** always on
* **Internal render size:** 320× 240 (always)
* **Windowing:** letterboxed into whatever window size you choose
* **Screen coordinates:** origin at **bottom-left**
* Pixel addressable range: **x: 0..319** , **y: 0..239**
* Floats are allowed (subpixel placement), but the result is still “pixel-ish”
* **3D coordinates:** right-handed, **Z-up**
* **Units:** 1 unit = 1 meter
* **Angles:** radians (`pi` exists)
Assets:
* Models: `.glb`
* Textures: `.png` / `.jpg`
* Sounds: `.wav` / `.mp3` (converted to 16-bit PCM @ 44.1 kHz)
* Music: `.mid` / `.midi` via the embedded chip
Voices (maximum simultaneously):
* PS1: **24**
* N64: **24**
* Saturn: **32**
Voice cap includes *everything* : `play_sound()` voices + voices produced by the MIDI/chip.
Hot reload:
* The engine watches and reloads changed **models** and **textures** during development.
---
## The big rule: immediate mode
Lance 3D does not keep a “scene.” It draws what you ask it to draw **this frame** .
If you want:
* a scene tree → you build it
* a global “current material” → you build it
* an entity system → you build it
Lance 3D mostly retains **assets** , not **render intent** .
### What resets each frame
At the start of each `draw()` call, Lance 3D resets:
* camera
* lighting
* fog
* 2D clip/scissor
So if you want those, you set them in `draw()` .
---
## Getting started
A Lance 3D “game” is a folder. Asset paths are **absolute-from-game-root** , and you **omit extensions** .
* `"man/model"` loads `"man/model.glb"`
* `"tex/brick"` loads `"tex/brick.png"` or `"tex/brick.jpg"`
* `"sfx/hit"` loads `"sfx/hit.wav"` or `"sfx/hit.mp3"`
Top-level code runs once at startup: load assets, create meshes, initialize state.
### The main loop
You don’ t write the loop. You register callbacks.
```cell
var t = 0
register({
update(dt) { t += dt },
draw() { /* draw every frame */ }
})
```
set_fog({
enabled: false,
color: [0.5,0.6,0.7],
near: 10,
far: 80
})
* `update(dt)` once per frame, `dt` in seconds
* `draw()` once per frame
// lance3d material
{
color_map: texture | null, // optional
paint: color, // required (default: white)
coverage: "opaque" | "cutoff" | "blend",
face: "single" | "double",
lamp: "lit" | "unlit"
---
## Hello cube
```cell
def cube = make_cube(1, 1, 1)
def tex = load_texture("tex/brick")
def mat = {
color_map: tex,
paint: [1, 1, 1, 1],
coverage: "opaque",
face: "single",
lamp: "lit"
}
## Draw API
Loads a gltf model, returning an array of {mesh, material}, with materials compressed into a render3d material
load_model(path) -> array< {mesh, material}>
var a = 0
make_cube(w, h, d) -> mesh
make_sphere(r, segments=12) -> mesh
make_cylinder(r, h, segments=12) -> mesh
make_plane(w, h) -> mesh
register({
update(dt) { a += dt },
Gets information about the animations on a model
anim_info(model )
draw() {
set_style("ps1" )
Generates a pose that can be applied to a model
sample_pose(model, name, time )
camera_perspective(pi / 3, 0.1, 100)
camera_look_at(3, 3, 2, 0, 0, 0 )
Draws a model with a given base transform
draw_model(model, transform=null, pose=null)
set_lighting({
sun_dir: [0.3, -1, 0.2],
sun_color: [1, 1, 1],
ambient: [0.25, 0.25, 0.25]
})
Draws a mesh with a mate rial
draw_mesh(mesh, transform=null, material=null )
def trs = trs_ matrix(0, 0, 0, 0, 0, a, 1, 1, 1)
draw_mesh(cube, trs, mat )
}
})
```
load_sound(path) -> sound | null
If you see loud purple, you drew without a material. That’ s intentional.
play_sound(sound, opts={
volume: 1.0, // 0..1
pitch: 1.0, // 1.0 = normal
pan: 0.0, // -1 = left, 0 = center, 1 = right
loop: false // boolean
}) -> voice
---
stop_sound(voice)
## Time, logging, stats
// when drawing, a provided material will override defaults for the system
load_texture(path) -> texture
draw_billboard(texture, x, y, z, size=1.0, mat=null)
draw_sprite(texture, x, y, size=1.0, mat=null)
> **`time()` → number**
> Seconds since boot.
## Camera API
camera_look_at(
ex, ey, ez, // eye
tx, ty, tz, // target
upx = 0, upy = 1, upz = 0
)
> **`log(...args)` → void**
> Print to the log.
camera_perspective(fov_deg=60, near=0.1, far=1000)
camera_ortho(left, right, bottom, top, near=-1, far=1)
> **`stat(name)` → number**
> `name`: `"draw_calls" | "triangles" | "fps" | "memory_bytes"`
> Throws if unknown.
## Collision API
add_collider_sphere(transform, radius, opts={layer_mask:1, user:null}) -> collider
add_collider_box(transform, sx, sy, sz, opts={layer_mask:1, user:null}) -> collider
---
overlaps(layer_mask_a=null, layer_mask_b=null) -> array< {a: collider, b: collider}>
## Style (PS1 / N64 / Saturn)
raycast(ox,oy,oz, dx,dy,dz, opts={
max_dist: Infinity,
layer_mask: 0xFFFFFFFF
}) -> null | {x,y,z, nx,ny,nz, distance, collider}
Style is global and persists until changed.
retro3d.remove_collider(collider)
> **`set_style(style = "ps1")` → void**
> `style`: `"ps1" | "n64" | "saturn"`
Triangle limits are soft: warnings, not failures.
### Texture & model tiers (low / normal / hero)
Textures and models are treated as belonging to a tier and interpreted through the current style.
| Style | Low | Normal | Hero |
| ------ | --- | ------ | ---- |
| PS1 | 64 | 128 | 256 |
| N64 | — | 32 | 64 |
| Saturn | 32 | 64 | 128 |
Defaults:
* **Textures:** `normal`
* **Models:** `normal`
You can tag assets:
> **`tag_texture(texture, tier = "normal")` → void**
> **`tag_model(model, tier = "normal")` → void**
> `tier`: `"low" | "normal" | "hero"`
Tags are hints to the style system (budget + interpretation). They don’ t invent LODs for you.
---
## Environment (camera, lighting, fog)
These reset each `draw()` . Set them in `draw()` .
### Camera
> **`camera_look_at(ex, ey, ez, tx, ty, tz, upx = 0, upy = 0, upz = 1)` → void**
> **`camera_perspective(fov = pi/3, near = 0.1, far = 1000)` → void**
> FOV is radians. Throws if planes are invalid.
> **`camera_ortho(left, right, bottom, top, near = -1, far = 1)` → void**
### Lighting
> **`set_lighting({ sun_dir, sun_color, ambient })` → void**
> All colors are 0..1. `sun_dir` is normalized internally.
### Fog
> **`set_fog({ enabled=false, color, near, far })` → void**
> Fog is a first-class style tool.
---
## Transforms
Transforms are opaque TRS blobs created by `trs_matrix(...)` . Functions accept a transform blob or `null` for identity.
> **`trs_matrix(tx, ty, tz, rx, ry, rz, sx = 1, sy = 1, sz = 1)` → transform**
* translation in meters
* rotation is Euler **radians** , applied in **XYZ order**
* scale is unitless multiplier
(If you want quaternions, build your own helper and still feed the resulting matrix/blob shape the engine expects.)
---
## Materials
Materials are plain objects with a fixed shape. No shaders.
```cell
def brick = {
color_map: load_texture("tex/brick"),
paint: [1, 1, 1, 1],
coverage: "opaque",
face: "single",
lamp: "lit"
}
```
Fields:
* `color_map: texture | null`
* `paint: [r,g,b,a]` (0..1)
* `coverage: "opaque" | "cutoff" | "blend"`
* `"cutoff"` uses alpha threshold **0.5**
* `face: "single" | "double"`
* Front faces are **clockwise (CW)**
* `lamp: "lit" | "unlit"`
Transparency:
* `"blend"` draws are depth-sorted back-to-front (simple, stable, not perfect).
---
## Assets and paths
Loads are synchronous. Missing assets return `null` and log details.
> **`load_texture(path)` → texture | null**
> **`load_model(path)` → model | null**
> **`load_sound(path)` → sound | null**
> **`load_font(path)` → font | null**
Asset handles are stable by path:
* calling `load_texture("tex/brick")` again returns the same handle
* hot reload updates the content behind that handle in development
---
## Drawing order
1. 3D world (meshes, models, billboards)
2. 2D overlay (sprites, text, 2D primitives)
**2D always draws over 3D.**
To get “sprites in 3D,” use **billboards** .
---
## Drawing 3D
> **`draw_model(model, transform = null, pose = null)` → void**
> Uses embedded model materials.
> **`draw_mesh(mesh, transform = null, material = null)` → void**
> If `material=null`, uses the missing-material purple.
---
## Billboards (world space)
Billboards face the camera and are anchored at **bottom-center** .
> **`draw_billboard(texture, x, y, z, size = 1.0, material = null)` → void**
`size` is in **meters** (height). Aspect ratio comes from the texture.
---
## 2D drawing (PICO-8 style)
Everything here is screen space (320× 240), anchored at bottom-left, drawn over 3D.
### Sprites
> **`draw_sprite(texture, x, y, size = 1.0, material = null)` → void**
`size` is a pixel scale:
* `size=1.0` draws at the texture’ s authored pixel size in 320× 240 space
* `size=2.0` doubles it, etc.
### Text
Lance 3D ships with a built-in default font. You can also pass a font per draw.
> **`draw_text(text, x, y, scale = 1.0, color = [1,1,1,1], font = null)` → void**
* `\n` creates a new line
* unknown glyphs render as `?`
* `font=null` uses the built-in font
### Simple primitives
> **`pset(x, y, color = [1,1,1,1])` → void**
> **`line2(x0, y0, x1, y1, color = [1,1,1,1])` → void**
> **`rect(x, y, w, h, color = [1,1,1,1])` → void**
> **`rectfill(x, y, w, h, color = [1,1,1,1])` → void**
> **`circ(x, y, r, color = [1,1,1,1])` → void**
> **`circfill(x, y, r, color = [1,1,1,1])` → void**
> **`clip(x, y, w, h)` → void** (enable)
> **`clip()` → void** (disable; also resets each `draw()`)
---
## Procedural geometry
> **`make_cube(w = 1, h = 1, d = 1)` → mesh**
> **`make_sphere(r = 1, segments = 12)` → mesh**
> **`make_cylinder(r = 1, h = 1, segments = 12)` → mesh**
> **`make_plane(w = 1, h = 1)` → mesh**
---
## Animation
> **`anim_info(model)` → any**
> Structured info about available animations.
> **`sample_pose(model, name, t)` → pose | null**
> `t` is seconds. Nothing advances unless you advance it.
---
## Audio
Stereo output. Converted to 16-bit PCM @ 44.1kHz.
> **`play_sound(sound, { volume=1, pitch=1, pan=0, loop=false })` → voice**
> **`stop_sound(voice)` → void**
Embedded chip / MIDI:
> **`chip(channel, op, value)` → void**
> Examples:
* `chip(0, "play_midi", "music/theme")`
* `chip(0, "volume", 120)`
* `chip(2, "mute", 1)`
Voice stealing (when cap is hit) is deterministic:
* lowest priority loses first (chip voices are lowest by default)
* ties: oldest voice loses
---
## Collision (v1: simple queries)
Collision in v1 is **stateless queries** . No collider world, no layers, no retained physics state.
### Types
Use plain objects:
```cell
def ray = { origin: [ox, oy, oz], dir: [dx, dy, dz] } // dir should be normalized
def box = { min: [minx, miny, minz], max: [maxx, maxy, maxz] } // AABB
```
Ray hits return `hit | null` :
```cell
// when hit:
{ dist: number, point: [x,y,z], normal: [nx,ny,nz] }
// when miss:
null
```
### Shape checks
> **`check_collision_spheres(c1, r1, c2, r2)` → bool**
> **`check_collision_boxes(box1, box2)` → bool**
> **`check_collision_box_sphere(box, center, radius)` → bool**
### Ray queries
> **`get_ray_collision_sphere(ray, center, radius)` → hit | null**
> **`get_ray_collision_box(ray, box)` → hit | null**
> **`get_ray_collision_mesh(ray, mesh, transform = null)` → hit | null**
### Mesh collider (cached acceleration)
For repeated raycasts (picking, bullets, LOS checks), you can precompute a mesh collider once:
> **`make_mesh_collider(mesh)` → mesh_collider**
This builds an internal acceleration structure (BVH) in **mesh local space** .
Then use:
> **`get_ray_collision_mesh_collider(ray, mesh_collider, transform = null)` → hit | null**
Same result as `get_ray_collision_mesh` , but faster for repeated queries.
### Will this work?
Yes—this is a solid “fantasy-console collision” model **if you keep the expectations correct** :
* Great for: triggers, overlap tests, ray picking, hitscan weapons, simple AI line-of-sight.
* Not a physics engine: no solver, no continuous collision handling, no automatic contact resolution.
Mesh colliders specifically:
* Great when the mesh is **static** (level geometry) or the mesh itself doesn’ t change.
* Fine for moving instances: pass a different `transform` per query.
* If the mesh deforms/changes topology, you must rebuild the collider (call `make_mesh_collider` again).
---
## Input
btn(id, player=0) -> bool // held
btnp(id, player=0) -> bool // pressed this frame
## Math
seed(seed_int)
rand() -> number // [0,1)
irand(min_inclusive, max_inclusive) -> int
> **`btn(id, player = 0)` → bool**
> **`btnp(id, player = 0)` → bool**
## Debug API
vertex {x, y, z, u=0, v=0, color}
point(vertex, size=1.0)
line(vertex_a, vertex_b, width=1.0)
grid(size, step, norm = {x:0,y:1,z:0}, color)
Button IDs:
triangle(vertex_a, vertex_b, vertex_c, mat=null)
ray(ox,oy,oz, dx,dy,dz, len, opts)
* 0 left, 1 right, 2 down, 3 up
* 4 A, 5 B, 6 X, 7 Y
* 8 L, 9 R
* 10 Start, 11 Select
aabb(cx,cy,cz, ex,ey,ez, opts) // center+extents
obb(transform, sx,sy,sz, opts) // oriented via transform
frustum(camera_state_or_params, opts)
text3d(str, x,y,z, opts={size_px:12, depth:"always"|"test"})
---
Render a specific collider
collider(collider, color)
## Debug drawing (world space)
> **`dbg_line(a, b, color = [1,0,0])` → void**
> **`dbg_aabb(box, color = [1,0,0])` → void**
> **`dbg_ray(ray, color = [1,0,0])` → void**
---
# Cheat sheet (v1 API)
## Core
* `register({ update(dt), draw() })` // start the game loop
* `time()` // seconds since boot
* `log(...args)` // print to log
* `stat(name)` // runtime stats: draw_calls/triangles/fps/memory_bytes
## Style
* `set_style(style = "ps1")` // set visual style
* `tag_texture(texture, tier = "normal")` // tag texture as low/normal/hero
* `tag_model(model, tier = "normal")` // tag model as low/normal/hero
## Environment
* `camera_look_at(ex, ey, ez, tx, ty, tz, upx=0, upy=0, upz=1)` // set view
* `camera_perspective(fov=pi/3, near=0.1, far=1000)` // set perspective projection
* `camera_ortho(left, right, bottom, top, near=-1, far=1)` // set ortho projection
* `set_lighting({ sun_dir, sun_color, ambient })` // set sun + ambient
* `set_fog({ enabled=false, color, near, far })` // set fog
## Transforms
* `trs_matrix(tx, ty, tz, rx, ry, rz, sx=1, sy=1, sz=1)` // build transform blob (TRS)
## Assets
* `load_texture(path)` // load png/jpg, returns texture or null
* `load_model(path)` // load glb, returns model or null
* `load_sound(path)` // load wav/mp3, returns sound or null
* `load_font(path)` // load a font, returns font or null
## Procedural geometry
* `make_cube(w=1, h=1, d=1)` // cube mesh
* `make_sphere(r=1, segments=12)` // sphere mesh
* `make_cylinder(r=1, h=1, segments=12)` // cylinder mesh
* `make_plane(w=1, h=1)` // plane mesh
## Drawing 3D
* `draw_model(model, transform=null, pose=null)` // draw model with embedded materials
* `draw_mesh(mesh, transform=null, material=null)` // draw mesh with optional material override
* `draw_billboard(texture, x, y, z, size=1.0, material=null)` // camera-facing sprite in world
## Drawing 2D
* `draw_sprite(texture, x, y, size=1.0, material=null)` // screen-space sprite over 3D
* `draw_text(text, x, y, scale=1.0, color=[1,1,1,1], font=null)` // screen-space text
* `pset(x, y, color=[1,1,1,1])` // set pixel
* `line2(x0, y0, x1, y1, color=[1,1,1,1])` // draw line
* `rect(x, y, w, h, color=[1,1,1,1])` // rectangle outline
* `rectfill(x, y, w, h, color=[1,1,1,1])` // filled rectangle
* `circ(x, y, r, color=[1,1,1,1])` // circle outline
* `circfill(x, y, r, color=[1,1,1,1])` // filled circle
* `clip(x, y, w, h)` // enable 2D clipping
* `clip()` // disable 2D clipping
## Animation
* `anim_info(model)` // inspect available animations
* `sample_pose(model, name, t)` // sample pose at time t, returns pose or null
## Audio
* `play_sound(sound, { volume=1, pitch=1, pan=0, loop=false })` // play a sound voice
* `stop_sound(voice)` // stop a playing voice
* `chip(channel, op, value)` // control embedded chip / MIDI
## Collision
* `check_collision_spheres(c1, r1, c2, r2)` // sphere-sphere overlap
* `check_collision_boxes(box1, box2)` // AABB-AABB overlap
* `check_collision_box_sphere(box, center, radius)` // AABB-sphere overlap
* `get_ray_collision_sphere(ray, center, radius)` // ray vs sphere, returns hit or null
* `get_ray_collision_box(ray, box)` // ray vs AABB, returns hit or null
* `get_ray_collision_mesh(ray, mesh, transform=null)` // ray vs mesh, returns hit or null
* `make_mesh_collider(mesh)` // build cached collider (BVH) from mesh
* `get_ray_collision_mesh_collider(ray, mesh_collider, transform=null)` // fast ray vs mesh collider
## Input
* `btn(id, player=0)` // button held
* `btnp(id, player=0)` // button pressed this frame
## Debug
* `dbg_line(a, b, color=[1,0,0])` // debug line
* `dbg_aabb(box, color=[1,0,0])` // debug AABB
* `dbg_ray(ray, color=[1,0,0])` // debug ray