12345678Build a ball pit · Step 5 of 8
Make it a package
You'll learn
- Create a package with
pkg new - Split code into modules with
require - Get completion in your editor
- Time
- 15 min
- Builds on
- Step 4: Add physics
- File
ballpit/lib/pit.lua
Your pit.lua holds the physics, the toolbar and the code that starts
them, all in one file. In this step it becomes a package: a folder
DatumHue runs as one app, with the pit and the toolbar in modules of
their own.
Make a package
In the tutorial folder, have DatumHue make a package, then run it:
$ datumhue pkg new ballpit --name @alice/ball-pit
$ datumhue run ballpit
An empty window opens, and the terminal prints
hello from @alice/ball-pit. Close the window.
pkg newmakes the folderballpitand fills it with a package that runs.--namenames the package:@and a scope, then a slash and the package's own name. The scope stands for you, or the team that owns the package. The tutorial uses@alice; your own packages take your scope.datumhue runtakes a package's folder as it takes a script, and starts the package'sinit.lua.
The folder holds these files:
| Path | What it holds |
|---|---|
package.kdl | the manifest: which package this is, and what kind |
init.lua | the script that runs when the app starts |
lib/ | the package's modules |
assets/ | images, sounds and other files your app reads |
tests/ | the package's tests (step 7) |
datumhue.d.lua, .luarc.json, .stylua.toml | settings for your editor |
pkg new also writes a README.md, for people who open the folder.
Only the manifest, init.lua, lib/ and assets/ are part of the
package. DatumHue leaves the rest out when it runs the package or
packs it to share in step 8.
Read the manifest
package.kdl says what the package is. Open the one pkg new wrote,
and change its api_version line so that it reads like this:
name "@alice/ball-pit"
version "0.1.0"
api_version "*"
kind "app"
license "0BSD"
nameis the package's identity, the one you gavepkg new.versionnumbers its releases.api_versionis the range of DatumHue API versions the package works with.pkg newwrites a range from the version you have up to its next change that breaks older apps.*takes every version, so your package keeps loading as you update DatumHue. After an update that breaks older apps, check that it still works.kind "app"makes it an app: a package people run.licensesays what others may do with your package. Share it picks one.
Manifest reference lists every key a manifest takes.
Move the pit into a module
A module is a .lua file under lib/ that returns a value,
usually a table of functions. Your code loads it with require and its
path under lib/, without .lua: require("pit") loads lib/pit.lua.
Move the physics of pit.lua into ballpit/lib/pit.lua. The marked
lines are new; the rest is step 4's code:
local color = datumhue.color
local vec2 = datumhue.math.vec2
local W, H, R = 480, 320, 12
local COLORS = { "#e67e80", "#dbbc7f", "#7fbbb3" }
datumhue.physics.gravity = vec2(0, -600)
local canvas = datumhue.draw.new({
width = W,
height = H,
background = color("#232a2e"),
})
local function wall(x, y, width, height)
local rect = canvas:rect({
pos = vec2(x, y),
width = width,
height = height,
color = color("#475258"),
})
rect:add_body({ type = "static" })
end
wall(-W / 2, -H / 2, W, 10) -- floor
wall(-W / 2, H / 2 - 10, W, 10) -- ceiling
wall(-W / 2, -H / 2, 10, H) -- left
wall(W / 2 - 10, -H / 2, 10, H) -- right
New line: local pit = {}
local balls = {}
New line: local listeners = {}
New line: local function changed()
New line: for _, listener in ipairs(listeners) do
New line: listener(#balls)
New line: end
New line: end
New line:
New line: function pit.add()
local x = math.random(-200, 200)
local y = math.random(0, 120)
local ball = canvas:circle({
pos = vec2(x, y),
radius = R,
color = color(COLORS[#balls % #COLORS + 1]),
})
ball:add_body({
type = "dynamic",
shape = "circle",
restitution = 0.7,
mass = 1,
})
table.insert(balls, ball)
New line: changed()
end
New line: function pit.shake()
for _, ball in ipairs(balls) do
local kick = vec2(math.random(-200, 200), 400)
ball:apply_impulse(kick)
end
end
New line: function pit.clear()
for _, ball in ipairs(balls) do
ball:remove()
end
balls = {}
New line: changed()
end
New line: function pit.count()
New line: return #balls
New line: end
New line: function pit.on_change(listener)
New line: table.insert(listeners, listener)
New line: end
New line: function pit.mount(parent)
New line: canvas:mount({ parent = parent, width = W, height = H })
end
New line:
New line: return pit
In this example: datumhue.color datumhue.math.vec2 datumhue.physics.gravity datumhue.draw.new DrawCanvas:rect DrawPrimitive:add_body DrawCanvas:circle DrawCanvas:mount
- The canvas, the walls and the balls stay local to the module. It
shares only the table
pit, which it returns. pit.add,pit.shakeandpit.clearare step 4's functions under new names.pit.countsays how many balls the pit holds.pit.on_changetakes a function, a listener, thataddandclearcall with each new count. The pit no longer touches the counter: whatever shows the count listens for it.pit.mountshows the canvas inside the panel you give it.
Move the toolbar into a module
The toolbar goes in a module of its own, ballpit/lib/toolbar.lua. It
loads the pit with the same require:
local pit = require("pit")
local theme = datumhue.theme
local toolbar = {}
function toolbar.new(parent)
local row = parent:panel({
flex_direction = "row",
align_items = "center",
gap = 8,
})
local add = row:button({ text = "Add ball" })
local shake = row:button({ text = "Shake" })
local clear = row:button({ text = "Clear" })
local count = row:label({
text = "Balls: " .. pit.count(),
color = theme.token("accent_primary"),
})
add:on_click(pit.add)
shake:on_click(pit.shake)
clear:on_click(pit.clear)
datumhue.input.on_key(function(key, pressed)
if key == "a" and pressed then
pit.add()
end
end)
pit.on_change(function(n)
count.text = "Balls: " .. n
end)
return { add = add, count = count }
end
return toolbar
In this example: datumhue.theme UiElement:panel UiElement:button UiElement:label datumhue.theme.token datumhue.input.on_key
toolbar.newbuilds the row of buttons insideparent.- The buttons and the A key call the pit's functions directly.
- The listener keeps the counter in step with the pit, however a ball arrives or leaves.
- It returns the Add ball button and the counter, for the tests of step 7.
Wire them together
Replace the init.lua that pkg new wrote with this, then run the
folder with datumhue run --watch ballpit:
local pit = require("pit")
local toolbar = require("toolbar")
local screen = datumhue.ui.root:panel({
width = "100%",
height = "100%",
flex_direction = "column",
justify_content = "center",
align_items = "center",
gap = 12,
})
toolbar.new(screen)
pit.mount(screen)
for _ = 1, 10 do
pit.add()
end
In this example: datumhue.ui.root UiElement:panel
The pit looks and works as it did in step 4.
- A module runs once per app, the first time something requires it.
Every later
requirereturns the same table, soinit.luaandtoolbar.luashare one pit. - The toolbar goes into
screenfirst, then the pit below it.
Get completion in your editor
The editor files pkg new wrote describe the DatumHue API of the
version you have. Open the ballpit folder in an editor that runs the
Lua language server, LuaLS, and it completes every datumhue call and
checks what you pass. After you update DatumHue, write the files again
with pkg refresh:
$ datumhue pkg refresh ballpit
Set up your editor shows how to install LuaLS, and StyLua to format your code.
Try this
- Give the pit a
pit.drop(n)that addsnballs, and a Drop 5 button that calls it. - Move
COLORSinto a module of its own,lib/colors.lua, and require it frompit.lua. - Rename
lib/toolbar.luaand run the folder. What does the error name?
Recap
datumhue pkg newmakes a package: a manifest, aninit.lua, and folders for modules, assets and tests.datumhue runruns a package by its folder, starting atinit.lua.- A module is a file under
lib/that returns a value.requireruns it once and gives every caller the same value. - The editor files from
pkg newgive you completion for the whole API.