v0.15.0
2026-04-05
Playtesting
- [x] Chat log should do math, e.g. (2d12+12) * 2. Currently it only rolls the 2d12 and adds 12, without multiplying by 2. If it detects any mathematical expressions, it should execute them (after all die rolls are resolved).
- [x] Movement tracked during movement (shown in the Initiative Tracker) is only for current user. If user 1 moves 5 squares, they see 5 squares of movement; if user 2 moves the same token 2 squares, they see 2 and user 1 sees 5. All movement on a token should be totaled for this UI.
- [x] When a new chat message comes in, the chat log scrolls the new message into view. This is expected. However, during Initiative, the GM sees the initiative tracker underneath the latest message, and this logic scrolls all the way down. The auto-scroll when Initiative is active should change behavior: as a compromise between seeing the message and seeing the initiative tracker, it should scroll the new message to the halfway point of the visible chat log.
- Test results: this is not working. During initiative, each new chat messages scrolls all the way to the bottom. With a long initiative list, no chat messages are visible.
- [x] Allow user to select token (selected token ui) even if they are impersonating same token
- [x] When you roll dice in the chatlog by typing something like 2d12, it only shows one of the dice. The transformation rules should be:
- For each dice express (\d+d\d+), roll that die, and store the result(s)
- When all dice are processed, pass the chat message to the math processor:
- (2d12+12)*2 becomes (assume we rolled 5 and 7) (12+12)*2, and the math processor returns 48
- With all the math done, process the text into html:
- (2d12+12)*2 becomes ((html for the die result of 5)+(html for the die result of 7)+12)*2 = 48
- [x] Change Actions generated by processes such as Import Character and Generate Monster so they don't pre-roll dice. For instance, if an action's code is
return 'Attack: ' + random(20) + '+5';, change it toreturn 'Attack: 1d20+5';. This way, the chat log's automatic rolling and presentation of die rolls is preserved. - [x] Can't add token to intiative who wasn't there at start. When Initiative is active, add a context menu option for tokens on the map to "Add to Initiative". This rolls initiative and adds the token to the initiative order. If their number has already passed for the current turn, they missed their turn and the GM can decide to manually change it or just wait until the next round when their turn comes up as normal.
- [x] Restore the ... button for CurrentTokenBar actions that has context menu items like Edit and Delete.
- [x] Add a "New Action" button to the bottom of the actions tab in CurrentTokenBar
- [x] Add a "New Action" button to the bottom of the actions tab in the Edit Token modal
- [x] When CurrentTokenBar renders a token with no artwork, it should show a gray portrait with a ? (similar to the default token artwork on the map) in the portrait area. Currently it shows nothing.
Startup Optimization
- [x] There's no need to query the 5e API or System 8 wiki on every server startup, as the systems don't change that often. Store a timestamp of the last update, and limit updates to once per month, with an override flag that can be passed to 'pnpm dev'.
- [*] Add server capability to send emails, using SMTP servers configured in the environment variables.
Invite Player
- [*] When inviting players to a campaign, you should be able to select from a list of players already invited to other campaigns you own, in addition to the existing invite URL functionality.
- [x] This is working as described. Additionally, when the user is invited in this way, send an email to the user's clerk email address saying they were invited, with a link to the campaign landing page.
- [*] Add an option to invite by email. This option is only available if the user provides an SMTP configuration in the server settings. When this option is selected, the user can input an email address, and the system sends an email to that address with the invite link and instructions.
- [x] Show this option only if the server has SMTP configured. Hide the tab otherwise.
Client-side Image Processing
Client-Side Compression & Upload
- [x] Move compression threshold from 10MB to always-on (currently
compressFileForUpload()inimageUtils.tsonly triggers above 10MB) - [x] Apply category-specific max dimensions client-side (tokens: 512px, maps: 4096px) to match what the server currently enforces in
imageProcessor.ts - [x] Generate LODs in-browser via OffscreenCanvas in a Web Worker (LOD1: 1/2 res, LOD2: 1/4 res, matching current server-side thresholds of 250K and 2M pixels)
- [x] Upload directly to S3 via presigned URLs, bypassing the API server for image data entirely
- [x] Keep Sharp on the server only as a fallback for URL-based uploads (Google Image Search, image proxy)
API Changes
- [x] Add presigned URL endpoint: client requests a presigned S3 URL (with category, entityId, LOD level), server generates it, client uploads directly
- [x] Add image registration endpoint for post-upload metadata: after the client uploads to S3, it notifies the server with something like
POST /api/images/register { entityId, category, storagePath, lod?, width, height }so the DB and any listeners know the image (or LOD variant) is ready- This supports a progressive flow: client uploads a placeholder/thumbnail immediately, then processes and uploads the full image and LOD variants in background workers, registering each as completed
- Consider WebSocket events (e.g.
image:ready { entityId, lod }) so other clients in the campaign see new artwork without polling
- [x] Lower the
express.json()limit from 10MB to a reasonable level (e.g. 512KB or 1MB) to reduce DDoS surface, since image data will no longer flow through JSON endpoints - [x] Migrate base64-in-JSON patterns to binary upload endpoints:
POST /api/images/uploadcurrently acceptsdata:URLs (base64 image payloads in JSON body) — used by background removal and image proxy flows. Refactor so the client converts dataURLs to Blobs and uploads via the presigned S3 flow or the existing/api/images/upload-filemultipart endpointPOST /api/maps/importcurrently accepts entire ZIP files as base64 in JSON (has an existing TODO: "For now, we'll expect the ZIP content as base64 in the body"). Migrate to multipart form-data upload- Not sure what the use case is. If the user is initiating this in the UI, perhaps we can unzip the file client-side and upload the contents individually, then send a manifest to the server to process once all files are uploaded. If it's an API use case, multipart form-data with multiple files should work fine as well.
Map
CurrentTokenBar
- [x] For both impersonated and selected tokens, add a pin in the upper right corner (upper left for selected) of the on-hover element. When enabled, the element stays open even when not hovering over the portrait or the element.
Textures
- [x] Add the ability for users to upload textures, usable in the map foundation and drawings.
- Uploaded textures go into the library (there's an existing library asset type for textures)
- In the New/Edit Map modal, and the Fill tool for drawings, there are now three tabs for textures:
- Built-in (existing)
- User uploaded (only shows textures uploaded by the user) -- new UI
- Library (shows textures in the library, including user-uploaded) -- new UI
- It should be possible to upload a texture directly in the Library route as well
- [ ] Human todo: test this feature
Object Layer
- [x] Add a new token layer between "token" and "background" called "objects".
- It should be above map foundation and grid layer, but below token layer.
- It can contain tokens.
- Like on the background layer, tokens on this layer are not required to snap to grid, but the can if desired. They can be resized freely, and need not match grid size.
- Add a new token artwork type called "object", which is the default for tokens on this layer. This has no cropping, background removal, or anything--it just renders the image as-is, so it's ideal for things like trees, furniture, etc. Objects are encouraged to have transparent backgrounds, but it's not required.
- Re-use the background removal feature for portrait art for artwork of the 'object' type. Probably uses the same model and parameters. The idea is to help users who have an object image that doesn't have a transparent background, but they want to remove the background without having to do it themselves in an image editor.
Snapshots
- [x] Every time the map state changes (throttled to no more than one update per 15 seconds), save a snapshot image of the map. This should be simple, since WebGL is drawing to a canvas. Sample the smallest rectangle that contains all tokens on the map, with about a grid square of padding, and save the image as a webp on the server (local fs or s3, as configured by env vars), with medium-high quality compression at 512x512 size. Use this image for the map's card in /campaigns/:id routes (the "Choose a Map" UI)
Navigation
- [x] On all routes except /play/*, add a breadcrumb element underneath the page title. Leftmost is a house icon going to the root route (/). Example breadcrumbs:
- On /characters/parties/:id, the breadcrumbs would be "Home > Your Characters > Your Parties > [Party Name]", where "Home" goes to /, "Your Characters" goes to /characters, "Your Parties" goes to /characters/parties, and [Party Name] is not a link.
- [x] On the /characters route, add a link next to the title (Your Characters) but smaller, which leads to "Your Parties" (/characters/parties).
- [x] Remove the sub-menu items from the top-level nav, that being:
- Campaigns and Maps (under Campaigns)
- Characters and Parties (under Characters)
Campaigns
- [x] Add an cardImageUrl field to the Campaign entity (default null)
- [x] When the cardImageUrl is a valid-seeming url, load the image in the campaign card wherever campaign cards are shown
- [x] In Edit Campaign modal, add a field for cardImageUrl, with an option to upload an image file which then populates the cardImageUrl field with the uploaded image's URL.
- [x] Add a backgroundImageUrl field to the Campaign entity (default null)
- [x] When the backgroundImageUrl is a valid-seeming url, load the image as the background for the campaign landing page (/campaigns/:id) and the map selection page (/campaigns/:id/maps). Use a subtle overlay to ensure text is still readable on top of the image.
Character Sheet
- [x] Remove the play/build/chat tab bar from the character sheet.
- [x] Hide the custom sidebar on /characters/:id
- [x] In Campaigns and Parties inputs, if the user types a name that doesn't match an existing campaign or party, show an option to create that new campaign or party. On success, it shows a notification saying the new campaign/party exists, with a link to view it.
Token Properties and Bundled Systems
- [x] Add a new property type to System 8 and 5e: "Range".
- A range property has a min and a max. It can also have a current value between those two, inclusive. All three values must be an integer.
- In UIs where properties can be edited (edit token modal, currenttokenbar, special token UI for mobile), range properties show a slider, along with a button to manually set the value using a modal numeric input.
- [x] Change the default HP property in all bundled systems to be of type Range.
- Generate the usual migration scripts.
- Also generate a psql script to update the existing prod db in place. Do not execute this script.
- [x] Update bundled system data to change default property ordering thus:
- HP, AC, Speed, Proficiency Bonus (5e), Str, Dex, Con, Int, Wis, Cha, then everything else in alphabetical order.
- Generate the usual migrations as needed
- Generate a psql script to update the existing prod db in place. Do not execute this script.
Token Artwork
We're changing the workflow for token artwork, to streamline everyday use cases. Currently, each piece of artwork has a type (portrait, token, object, other). If you want token and portrait, you need two separate images. This will be changed, so that you only need one artwork object per unique source image. You can still upload as many images as you want. Each token has one or zero token images, and one or zero portrait images, but these can be the same image if desired.
In the UI, you will see the current strip of images, but this UI element will now be in the left 50% width pane of a two-pane container. In the right pane, there will be a slot for a token image and a slot for a portrait image. You can drag any image from the left pane into either slot on the right pane. If you drag an image into the token slot, it becomes the token image; if you drag an image into the portrait slot, it becomes the portrait image. If you drag an image that is already in one slot into the other slot, it becomes the image for that other slot as well, so you can have the same image for both token and portrait if desired. You can also have different images for each.
By default, image slots are empty. When an image is assigned to the token slot for the first time, it applies automatic AI tokenization. The token image slot shows the tokenized image. Above the image is a label saying "Token Image". Underneath that image is a pencil button to edit tokenization parameters, and a trash button to remove the token image (reverting to empty slot, not deleting the artwork). The pencil button opens the Tokenization Editor drawer, a new component reusing components of the existing image editor drawer, but without the left pane.
The Tokenization Editor drawer shows the following elements, mostly reusing components from the existing image editor. Top to bottom:
- A toggle, with options "tokenize" or "unchanged"
- The image, tokenized or not, per preference
- The label 'drag to pan, scroll to zoom'
- The AI-tokenize button
- A new two-pane horizontal container, with:
- Pan: a pair of numeric steppers for fine-tuning the pan in x and y directions
- Zoom: a slider for zoom level, with a reset button to return to default zoom
- Border Width slider
- Border Color selector with eyedropper, and a new trash button to set border color to transparent
- Background Color selector with eyedropper, AI-auto detect button, and X button to clear background color (changed to trash icon)
- A "replace source art" button that spawns the Source Image Editor drawer (see below).
When an image is assigned to the portrait slot for the first time, it does not apply any processing. The portrait image slot just shows the original image. Above the image is a label saying "Portrait Image". Underneath that image is a pencil button to edit background removal parameters, and a trash button to remove the portrait image (reverting to empty slot, not deleting the artwork). The pencil button opens the Background Removal Editor drawer, a new component reusing components of the existing image editor drawer, but without the left pane.
The Background Removal Editor drawer shows the following elements, mostly reusing components from the existing image editor. Top to bottom:
- A toggle, with options "remove background" or "unchanged"
- The image, with background removed or not, per preference
- Remove Background button removed -- this happens automatically when the user toggles "remove background" on
- Refine button, which triggers server-side AI background removal. Currently this uses OpenAI
- A Change Source Art button that spawns the Source Image Editor drawer (see below).
When a new image is dragged onto a token or portrait slot that already has an image, the existing preferences are applied. That is, if the user was using the default ai tokenization for the old image, that will be applied for the new one. If they were choosing to use the source image for token art instead, then the new image will also be used as token art with no tokenization. The same goes for background removal, and other token properties like border color, thickness, etc. This way, the user can set their preferences for how they want images processed, and then just drag and drop new images without having to reset those preferences each time. (pan/zoom are not preserved for tokenization since they will likely differ greatly between images, but if the old image had AI tokenization, that will be applied. Otherwise, new images get the default pan/zoom)
The current image strip is unchanged, except that the labels for 'portrait' and 'token' are removed. The pencil's behavior changes to spawn the Source Image Editor drawer, a new component. Before describing it, it is worth noting that, in the tokenization and portrait editor drawers, there is a button that spawns this same component.
The Source Image Editor drawer is a new component that reuses components of the existing drawer. Its components are as follows, top to bottom:
- The source image, with no processing applied
- Two buttons beneath it:
- Replace (with recycle icon)
- Delete (with trash icon)
When the user clicks Replace, it reveals new components below in the same drawer:
- A tab container, with tabs on the left and content on the right. The tabs are:
- Upload From My Device (default)
- Google Image Search
- Load From URL
- Choose From Library
- Each tab's content is the exact same as the current UI for each of those options in the Image Editor drawer
After choosing a new image, the image is pre-processed on the client (already implemented), and uploaded (local FS is uploaded via our API, S3 is directly uploaded from the client). When complete, PATCH events update the token itself.
If the replaced artwork was the current portrait or token image, the new artwork becomes the portrait or token image as appropriate. The same processing preferences are applied to the new image as were applied to the old image, as described above.
Data model changes
- [x] Remove
artworkTypefromArtworkEntity. Artworks are no longer typed; any artwork can be used as token image, portrait image, or both. TheartworkTypefield,ArtworkTypetype, and all code branching on it should be removed. - [x] Add
tokenArtworkIdandportraitArtworkIdfields (nullable strings) toIToken. These reference an artwork ID in the token'sartworks[]array. When set,getPrimaryTokenArt()should return the artwork matchingtokenArtworkId; a newgetPortraitArt()should return the artwork matchingportraitArtworkId. Both can point to the same artwork ID. - [x] Update
migrateTokenToArtworksto settokenArtworkIdandportraitArtworkIdbased on the legacy artwork types (token-art → tokenArtworkId, portrait → portraitArtworkId). - [x] Update
getPrimaryTokenArt,getMapImageUrl,getCharacterDisplayArt, andhasTokenArtto use the new ID-based lookup instead of filtering byartworkType. - [x] Generate DB migration to add
tokenArtworkIdandportraitArtworkIdcolumns to the tokens table, and generate a psql script to backfill them from the existingartworksJSON.
UI: Two-pane artwork layout in General tab
- [x] Replace the current artwork thumbnail strip in GeneralTab with a two-pane layout: left pane (50%) shows the image strip, right pane (50%) shows token image and portrait image slots.
- [x] Image strip (left pane): remove the portrait/token type labels from thumbnails. Keep add (+) button. Pencil button now opens the Source Image Editor drawer (see below).
- [x] Token Image slot (right pane, top): shows the tokenized image when
tokenArtworkIdis set, or an empty drop target when unset. Label "Token Image" above, pencil button (opens Tokenization Editor drawer) and trash button (clearstokenArtworkId, does not delete artwork) below. - [x] Portrait Image slot (right pane, bottom): shows the portrait image when
portraitArtworkIdis set, or an empty drop target when unset. Label "Portrait Image" above, pencil button (opens Background Removal Editor drawer) and trash button (clearsportraitArtworkId, does not delete artwork) below. - [x] Implement drag-and-drop from image strip thumbnails to either slot. On drop, set
tokenArtworkIdorportraitArtworkIdto the dropped artwork's ID. If the artwork was already in the other slot, keep it there too (both slots can reference the same artwork).
UI: Tokenization Editor drawer
- [x] Create a new
TokenizationEditorDrawercomponent, reusing elements fromTokenArtworkEditorandTokenizedArtworkView. No left pane / image strip—just the editor for the selected token artwork. - [x] Contents top to bottom: tokenize/unchanged toggle, image preview (tokenized or original per toggle), "drag to pan, scroll to zoom" label, AI-tokenize button, two-pane pan (x/y numeric steppers) + zoom (slider with reset), border width slider, border color selector (with eyedropper and transparent/trash button), background color selector (with eyedropper, AI-auto detect, and clear/trash button), "Replace Source Art" button that opens the Source Image Editor drawer.
- [x] When an artwork is first assigned to the token slot, auto-apply AI tokenization (existing flow from TokenArtworkEditor).
UI: Background Removal Editor drawer
- [x] Create a new
BackgroundRemovalEditorDrawercomponent, reusing elements fromBackgroundRemovalPanel. No left pane—just the editor for the selected portrait artwork. - [x] Contents top to bottom: remove-background/unchanged toggle, image preview (background removed or original per toggle), Refine button (server-side AI background removal), "Change Source Art" button that opens the Source Image Editor drawer.
- [x] When the user toggles "remove background" on, trigger background removal automatically (no separate button).
UI: Source Image Editor drawer
- [x] Create a new
SourceImageEditorDrawercomponent. Shows: the source image (no processing), a Replace button (recycle icon), and a Delete button (trash icon). - [x] Replace button reveals a tab container below with tabs: Upload From My Device (default), Google Image Search, Load From URL, Choose From Library — each tab reuses the existing UI components (
ImageDropZone,GoogleImageSearch,LibraryPicker, URL input). - [x] After choosing a new image, client pre-processes and uploads it (existing flow). PATCH updates the artwork's
imageUrl/originalUrl. If the replaced artwork is the current token or portrait image, re-apply the same processing preferences (tokenization settings, background removal) to the new source image. Pan/zoom are reset, but all other settings (border, background color, tokenize toggle, etc.) are preserved.
Preference preservation on image change
- [x] When a new image is dragged onto a token slot that already has an image, apply the existing tokenization preferences (tokenize toggle, border width/color, background color, AI tokenization if it was enabled). Reset pan/zoom to defaults since the new image will have different dimensions.
- [x] When a new image is dragged onto a portrait slot that already has an image, apply the existing background removal preference (remove-background toggle, cutout if it was enabled).
Cleanup
- [x] Remove the
artworkTypeselector fromTokenArtworkEditor(no longer needed since artwork type is determined by which slot it's assigned to). - [x] Update
ArtworkTabto use the new two-pane layout and slot-based workflow, or remove it if the General tab now covers all artwork editing functionality. - [x] Update CurrentTokenBar, SelectedTokenBar, and any other components that read artwork to use
tokenArtworkId/portraitArtworkIdlookups instead of filtering byartworkType.
Bugs
- [x] The CurrentTokenBar's on-hover content is blocking map clicks even when not visible. This is because the entire area of the component is technically "on hover", even the transparent parts. We should move the on-hover content to a sibling element that only appears on hover, so it won't block clicks when it's not visible.
- [x] When closing devtools (possibly other window resize events), the zoom level changes radically. For example, it will be zoomed so I can see like 12 squares across with devtools open, then I close it, and suddenly I can fit maybe 3 squares. The zoom level shouldn't change based on window size. If I deliberately resize the window, I can't reproduce the zoom effect.
- [x] The size dropdown in Create Token modal doesn't work, it always sticks with medium. Other means of changing token size work as expected.
- [x] The 'reorder' function isn't working for system 8 (possibly others). If I drag a property to a new location to change order, it snaps back to its previous location. This is a UI only issue, as I can see successful PATCH calls for all the properties, but the order doesn't actually change in the UI.
- [x] In the Change Size context menu item on tokens, the current size isn't correctly indicated. There is a checkmark next to Medium regardless of the token's actual size. This is just a UI issue, as the correct size is being applied when I select a new size, and the correct size is shown in the Edit Token modal.