Files
retro3d/website/manual.md
2025-12-21 10:39:05 -06:00

14 KiB
Raw Permalink Blame History

Lance 3D Manual (v1)

Lance 3D is a focused 3D fantasy-console engine for making games with a PS1 / N64 / Saturn kind of visual personality. Its immediate-mode: you load some assets, set a camera, draw every frame, ship.

Game code is written in CellScript (not JavaScript):

https://cell-lang.org/cellscript/

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 dont write the loop. You register callbacks.

var t = 0

register({
  update(dt) { t += dt },
  draw() { /* draw every frame */ }
})
  • update(dt) once per frame, dt in seconds
  • draw() once per frame

Hello cube

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"
}

var a = 0

register({
  update(dt) { a += dt },

  draw() {
    set_style("ps1")

    camera_perspective(pi / 3, 0.1, 100)
    camera_look_at(3, 3, 2, 0, 0, 0)

    set_lighting({
      sun_dir: [0.3, -1, 0.2],
      sun_color: [1, 1, 1],
      ambient: [0.25, 0.25, 0.25]
    })

    def trs = trs_matrix(0, 0, 0, 0, 0, a, 1, 1, 1)
    draw_mesh(cube, trs, mat)
  }
})

If you see loud purple, you drew without a material. Thats intentional.


Time, logging, stats

time() → number Seconds since boot.

log(...args) → void Print to the log.

stat(name) → number name: "draw_calls" | "triangles" | "fps" | "memory_bytes" Throws if unknown.


Style (PS1 / N64 / Saturn)

Style is global and persists until changed.

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 dont 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.

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 textures 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:

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:

// 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 doesnt 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 btnp(id, player = 0) → bool

Button IDs:

  • 0 left, 1 right, 2 down, 3 up
  • 4 A, 5 B, 6 X, 7 Y
  • 8 L, 9 R
  • 10 Start, 11 Select

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