Skip to main content

Key-Value Storage

Dreamlab games are stateless by default, when a new instance is created the game is started fresh every time. In order to persist state between instances you can use the Key-Value (KV) API.

Data stored in Dreamlab KV is scoped to the project, you cannot have cross-project persistent data. KV data is also scoped per-player, and players (clients) can only read/write to their own scope. The server has its own scope, and is also able to read/write to any player scopes.

The KV API can be accessed by this.game.kv and exposes get / set / list / delete / clear methods. KV keys must be of string type, and values can be any JSON serializable value.

KV Debugger

Attach this behavior script to a UILayer in the world root to debug the KV data stored in your project.

kv-debugger.tsx
import { JsonValue, rpc, UIBehavior, UILayer } from "@dreamlab/engine";

export default class KvDebugger extends UIBehavior {
#player: string = "";
#players = new Set<string>();
#list: Record<string, JsonValue> = {};
#isVisible = true;
#editingKey = "";

@rpc.server()
async getKv(playerId: string) {
if (!this.game.isServer()) throw new Error("rpc not on server");
const kv = this.game.kv;

const [players, list] = await Promise.all([
kv.info.players(),
playerId === "" ? kv.server.list() : kv.player.list(playerId),
]);

return { players: [...players], list };
}

@rpc.server()
async #setValue(playerId: string, key: string, value: JsonValue): Promise<void> {
if (!this.game.isServer()) throw new Error("rpc not on server");
const kv = this.game.kv;

if (playerId === "") await kv.server.set(key, value);
else await kv.player.set(key, value, playerId);
}

@rpc.server()
async #deleteValue(playerId: string, key: string): Promise<void> {
if (!this.game.isServer()) throw new Error("rpc not on server");
const kv = this.game.kv;

if (playerId === "") await kv.server.delete(key);
else await kv.player.delete(key, playerId);
}

@rpc.server()
async #clearValues(playerId: string): Promise<void> {
if (!this.game.isServer()) throw new Error("rpc not on server");
const kv = this.game.kv;

if (playerId === "") await kv.server.clear();
else await kv.player.clear(playerId);
}

async #update(): Promise<void> {
if (!this.game.isClient()) return;

const { players, list } = await this.getKv(this.#player);
this.#players = new Set(players);
this.#list = list;

this.rerender();
}

onInitialize(): void {
super.onInitialize();
if (!this.game.isClient()) return;

const css = `
div.container {
font-family: var(--font-sans);
width: 100%;
position: absolute;
bottom: 0px;
min-height: 300px;
max-height: 500px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
background: #252a36;
padding: 1rem;

& h1 {
margin: 0;
}

& div.selector {
display: flex;
gap: 1rem;
align-items: center;

& label {
color: #e6e8ee;
font-weight: 600;
}

& select {
font-family: var(--font-sans);
background: #1f2330;
color: #e6e8ee;
border: 1px solid rgb(255 255 255 / 20%);
border-radius: 4px;
padding: 0.35rem 0.5rem;
}
}

& div.data {
flex-grow: 1;
overflow-y: auto;
padding-bottom: 2rem;
padding-right: 2rem;


& > span {
font-style: italic;
color: #c8ccdb;
}

& table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
background: #1f2330;
border-radius: 4px;
overflow: hidden;
}

& thead {
position: sticky;
top: 0;
background: #2b3040;
z-index: 1;
}

& tr:not(:last-child) {
border-bottom: 1px solid rgb(255 255 255 / 8%);
}

& th, & td {
text-align: left;
vertical-align: top;
padding: 0.6rem 0.8rem;
color: #e6e8ee;
}

& th:nth-child(1), & td:nth-child(1) {
width: 25%;
font-weight: 700;
word-break: break-all;
}

& th:nth-child(2), & td:nth-child(2) {
width: 45%;
}

& th:nth-child(3), & th:nth-child(4),
& td:nth-child(3), & td:nth-child(4) {
width: 15%;
text-align: center;
}

& code, & pre {
margin: 0;
padding: 0;
font-family: var(--font-mono), monospace;
font-size: 0.9rem;
color: #e6e8ee;
white-space: pre-wrap;
word-break: break-word;
}

& pre {
background: transparent;
}

& textarea {
border-radius: 4px;
border: 1px solid rgb(255 255 255 / 20%);
background: #2f3545;
color: #fff;
padding: 0.4rem;
}
}
}

button {
font-family: var(--font-sans);
padding: 0.25rem 0.6rem;
border-radius: 4px;
border: 1px solid rgb(255 255 255 / 20%);
background: #2f3545;
color: #fff;
font-size: 0.8rem;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease;
}

button:hover {
background: #3f465a;
opacity: 1;
}

button:active {
transform: scale(0.95);
}

button.kv-toggle {
font-family: var(--font-sans);
position: absolute;
right: 12px;
bottom: 12px;
height: 30px;
border-radius: 4px;
border: 1px solid rgb(255 255 255 / 25%);
background: #1f2330;
color: #fff;
opacity: 0.7;
cursor: pointer;
display: grid;
place-items: center;
font-size: 0.9rem;
line-height: 1;
z-index: 1000;
transition: opacity 0.15s ease, transform 0.1s ease;
}

button.kv-toggle:hover { opacity: 1; }
button.kv-toggle:active { transform: scale(0.96); }
`;

this.setCss(css);
this.entity.cast(UILayer).element.style.zIndex = "999";
this.#update();
}

render() {
const entries = Object.entries(this.#list);

return (
<div className="kv-root">
<div className="container" style={{ display: this.#isVisible ? undefined : "none" }}>
<div>
<h1 style={{ display: "inline-block" }}>KV Database Editor</h1>
<button
onClick={() => void this.#update()}
style={{ maxWidth: "200px", display: "inline-block", marginLeft: "1rem" }}
>
Reload
</button>
</div>

<div className="selector">
<label htmlFor="player">Player Selector</label>
<select
id="player"
onChange={(ev) => {
if (!ev.target) return;
if (!(ev.target instanceof HTMLSelectElement)) return;

this.#player = ev.target.value;
this.#update();
}}
value={this.#player}
>
<option value="">[server]</option>
{[...this.#players].map((player) => (
<option id={player} value={player}>
{player}
</option>
))}
</select>
</div>

<div className="data">
{entries.length === 0 ? <span>No data stored.</span> : (
<table>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Edit</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
{entries.map(([key, value]) => (
<tr id={key}>
<td>
<code>{key}</code>
</td>
<td>
{this.#editingKey === key
? (
<textarea data-editor data-key={key}>
{JSON.stringify(value, null, 2)}
</textarea>
)
: <pre>{JSON.stringify(value, null, 2)}</pre>}
</td>
<td>
<button
type="button"
onClick={async () => {
const query = `textarea[data-editor][data-key="${key}"]`;

if (this.#editingKey !== key) {
this.#editingKey = key;
this.rerender();

const textarea = this.ui.element.querySelector<
HTMLTextAreaElement
>(query);
if (!textarea) return;

textarea.focus();
textarea.selectionStart = textarea.value.length;
textarea.rows = textarea.value.split("\n").length;
} else {
const textarea = this.ui.element.querySelector<
HTMLTextAreaElement
>(query);

if (!textarea) return;
try {
const newValue = JSON.parse(textarea.value);
await this.#setValue(this.#player, key, newValue);
this.#editingKey = "";
await this.#update();
} catch (err) {
alert(err);
}
}
}}
>
{this.#editingKey !== key ? "Edit" : "Save"}
</button>
</td>
<td>
<button
type="button"
onClick={async () => {
await this.#deleteValue(this.#player, key);
await this.#update();
}}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>

<button
type="button"
className="kv-toggle"
aria-label={this.#isVisible ? "Hide KV Debugger" : "Show KV Debugger"}
title={this.#isVisible ? "Hide KV Debugger" : "Show KV Debugger"}
onClick={() => {
this.#isVisible = !this.#isVisible;
this.rerender();
}}
>
{this.#isVisible ? "Close KV" : "Open KV"}
</button>
</div>
);
}
}